花伊浓

花伊浓 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

花伊浓 关注了用户 · 2020-09-02

Lizzy0077 @lizzy0077

前端

关注 6

花伊浓 发布了文章 · 2020-04-14

FastClick 源码解析

faskclick是为了消除在移动端上用户的点击后有300-350ms延迟,这是因为在移动端浏览器为了确认当前用户是否为双击。300-350ms的延迟是在touchend与click之间。

使用方式如下:

var FastClick = require('fastclick')
FastClick.attach(document.body)

一、引用时得到的什么?

if (typeof define === 'function' && typeof define.amd === 'object' && define.amd) {
    // AMD. Register as an anonymous module.
    define(function() {
        return FastClick;
    });
} else if (typeof module !== 'undefined' && module.exports) {
    module.exports = FastClick.attach;
    module.exports.FastClick = FastClick;
} else {
    window.FastClick = FastClick;
}

这里兼容了三种方式:AMD、CMD或直接赋值在window上;不管用哪种方式引用,最终拿到的是FastClick

二、FastClick.attach做了什么?

FastClick.attach = function(layer, options) {
    return new FastClick(layer, options);
};

它是FastClick方法的静态方法,调用它就是创建了一个FaskClick实例。

三、FastClick实例初始化了什么?

function FastClick(layer, options) {
    // ...
}

FastClick的构造函数共分为6部分:

1、初始了一系列属性;

var oldOnClick;
options = options || {};
// Whether a click is currently being tracked.
this.trackingClick = false;
// Timestamp for when click tracking started.
this.trackingClickStart = 0;
// The element being tracked for a click.
this.targetElement = null;
// X-coordinate of touch start event.
this.touchStartX = 0;
// Y-coordinate of touch start event.
this.touchStartY = 0;
// ID of the last touch, retrieved from Touch.identifier.
this.lastTouchIdentifier = 0;
// Touchmove boundary, beyond which a click will be cancelled.
this.touchBoundary = options.touchBoundary || 10;
// The FastClick layer.
this.layer = layer;
// The minimum time between tap(touchstart and touchend) events
this.tapDelay = options.tapDelay || 200;
// The maximum time for a tap
this.tapTimeout = options.tapTimeout || 700;

2、调用FastClick的静态函数notNeeded判断当前环境是否支持,如果不支持则返回true,否则返回false;

if (FastClick.notNeeded(layer)) {
    return;
}

FastClick.notNeeded方法主要针对如下几种情况视为无需使用FastClick,具体如下:
1、如果设备不支持touch的

// Devices that don't support touch don't need FastClick
if (typeof window.ontouchstart === 'undefined') {
    return true;
}

2、针对使用Chrome浏览器的设备,一些几种情况不加FastClick:
(1)在安卓设备上,Chrome发布的第一个版本就删除了touchend与click之间的延时,虽然这也使得页面不可缩放,但是从Chrome 32开始,消除了touchend与click之间的延时同时也支持页面可缩放。随后Firefox与IE/Edge也做了相同的设置,2016年3月,IOS 9.3也是;
只需要页面的head中设置meta标签user-scalable="no"或是在Chrome 32及以上设置width=device-with即可自动禁用300-350ms的延时,无需再FastClick
(2)在其他设备上,即桌面设备,桌面设备更不需要
在Windows 7及以上的可触摸设备上,使用Chrome打开window.ontouchstart != undefined,这些设备可能会有接入外部的鼠标或键盘,可以点击或是触摸的,在这些设备上添加触摸事件的监听会导致页面不可点击

// Chrome version - zero for other browsers
chromeVersion = +(/Chrome\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1];
if (chromeVersion) {
    if (deviceIsAndroid) {
        metaViewport = document.querySelector('meta[name=viewport]');
        if (metaViewport) {
            // Chrome on Android with user-scalable="no" doesn't need FastClick (issue #89)
            if (metaViewport.content.indexOf('user-scalable=no') !== -1) {
                return true;
            }
            // Chrome 32 and above with width=device-width or less don't need FastClick
            if (chromeVersion > 31 && document.documentElement.scrollWidth <= window.outerWidth) {
                return true;
            }
        }

    // Chrome desktop doesn't need FastClick (issue #15)
    } else {
        return true;
    }
}

3、BlackBerry手机10.3+已经不再有300ms的延时,因此无需处理

if (deviceIsBlackBerry10) {
    blackberryVersion = navigator.userAgent.match(/Version\/([0-9]*)\.([0-9]*)/);
    // BlackBerry 10.3+ does not require Fastclick library.
    // https://github.com/ftlabs/fastclick/issues/251
    if (blackberryVersion[1] >= 10 && blackberryVersion[2] >= 3) {
        metaViewport = document.querySelector('meta[name=viewport]');
        if (metaViewport) {
            // user-scalable=no eliminates click delay.
            if (metaViewport.content.indexOf('user-scalable=no') !== -1) {
                return true;
            }
            // width=device-width (or less than device-width) eliminates click delay.
            if (document.documentElement.scrollWidth <= window.outerWidth) {
                return true;
            }
        }
    }
}

4、在IE10上,如果设置了-ms-touch-action: none/manipulation,就会禁止放大功能,因为此时也无需处理延时问题;Firefox 27+ 如果在viewport标签上已经设置了user-scalable=no或width=device-width,表示禁止缩放,此时也是不存在延时的;其他情况设置-ms-touch-action: none/manipulation也是需要处理延时问题。

// IE10 with -ms-touch-action: none or manipulation, which disables double-tap-to-zoom (issue #97)
if (layer.style.msTouchAction === 'none' || layer.style.touchAction === 'manipulation') {
    return true;
}
// Firefox version - zero for other browsers
firefoxVersion = +(/Firefox\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1];
if (firefoxVersion >= 27) {
    // Firefox 27+ does not have tap delay if the content is not zoomable - https://bugzilla.mozilla.org/show_bug.cgi?id=922896
    metaViewport = document.querySelector('meta[name=viewport]');
    if (metaViewport && (metaViewport.content.indexOf('user-scalable=no') !== -1 || document.documentElement.scrollWidth <= window.outerWidth)) {
        return true;
    }
}
// IE11: prefixed -ms-touch-action is no longer supported and it's recomended to use non-prefixed version
// http://msdn.microsoft.com/en-us/library/windows/apps/Hh767313.aspx
if (layer.style.touchAction === 'none' || layer.style.touchAction === 'manipulation') {
    return true;
}
return false;

3、给当前的实例的一系列事件['onMouse', 'onClick', 'onTouchStart', 'onTouchMove', 'onTouchEnd', 'onTouchCancel']绑定this的指向,指向当前的实例;

// Some old versions of Android don't have Function.prototype.bind
function bind(method, context) {
    return function() { return method.apply(context, arguments); };
}

var methods = ['onMouse', 'onClick', 'onTouchStart', 'onTouchMove', 'onTouchEnd', 'onTouchCancel'];
var context = this;
for (var i = 0, l = methods.length; i < l; i++) {
    context[methods[i]] = bind(context[methods[i]], context);
}

4、给当前的layer即我们的document.body绑定事件:click、touchstart、touchmove、touchend、touchcancel,如果是安卓手机,还会绑定mouseover、mousedown、mouseup事件,

这里注意,click、mouseover、mousedown、mouseup使用捕获的方式,其他使用冒泡的方式

// Set up event handlers as required
if (deviceIsAndroid) {
    layer.addEventListener('mouseover', this.onMouse, true);
    layer.addEventListener('mousedown', this.onMouse, true);
    layer.addEventListener('mouseup', this.onMouse, true);
}

layer.addEventListener('click', this.onClick, true);
layer.addEventListener('touchstart', this.onTouchStart, false);
layer.addEventListener('touchmove', this.onTouchMove, false);
layer.addEventListener('touchend', this.onTouchEnd, false);
layer.addEventListener('touchcancel', this.onTouchCancel, false);

5、判断是否存在stopImmediatePropagation,它主要是用来阻止冒泡和触发用户声明的事件监听回调,如果不存在,则重定义layer.removeEventListenerlayer.addEventListener,主要是兼容一些不支持stopImmediatePropagation的情况。

// Hack is required for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2)
// which is how FastClick normally stops click events bubbling to callbacks registered on the FastClick
// layer when they are cancelled.
if (!Event.prototype.stopImmediatePropagation) {
    layer.removeEventListener = function(type, callback, capture) {
        var rmv = Node.prototype.removeEventListener;
        if (type === 'click') {
            rmv.call(layer, type, callback.hijacked || callback, capture);
        } else {
            rmv.call(layer, type, callback, capture);
        }
    };

    layer.addEventListener = function(type, callback, capture) {
        var adv = Node.prototype.addEventListener;
        if (type === 'click') {
            adv.call(layer, type, callback.hijacked || (callback.hijacked = function(event) {
                if (!event.propagationStopped) {
                    callback(event);
                }
            }), capture);
        } else {
            adv.call(layer, type, callback, capture);
        }
    };
}

目前只在onMouse事件上调用了stopImmediatePropagation方法,它主要就是在事件类型为click且未阻止事件冒泡时才调用click事件的回调。

// Derive and check the target element to see whether the mouse event needs to be permitted;
// unless explicitly enabled, prevent non-touch click events from triggering actions,
// to prevent ghost/doubleclicks.
if (!this.needsClick(this.targetElement) || this.cancelNextClick) {
    // Prevent any user-added listeners declared on FastClick element from being fired.
    if (event.stopImmediatePropagation) {
        event.stopImmediatePropagation();
    } else {
        // Part of the hack for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2)
        event.propagationStopped = true;
    }
    // Cancel the event
    event.stopPropagation();
    event.preventDefault();

    return false;
}

6、判断当前用户是否有在layer上面直接定义onclick事件,如果有,将其转换为addEventListener的方式定义,它会在Fastclick的onClick回调之后触发;

// If a handler is already declared in the element's onclick attribute, it will be fired before
// FastClick's onClick handler. Fix this by pulling out the user-defined handler function and
// adding it as listener.
if (typeof layer.onclick === 'function') {
    // Android browser on at least 3.2 requires a new reference to the function in layer.onclick
    // - the old one won't work if passed to addEventListener directly.
    oldOnClick = layer.onclick;
    layer.addEventListener('click', function(event) {
        oldOnClick(event);
    }, false);
    layer.onclick = null;
}

四、FastClick在事件触发时做了什么?

这里我们只看touchstart、touchmove、touchend、click,按照事件执行顺序挨个分析。

1、touchstart

layer.addEventListener('touchstart', this.onTouchStart, false);

它调用了onTouchStart函数:

1、忽略多个手指操作页面时,如缩放
// Ignore multiple touches, otherwise pinch-to-zoom is prevented if both fingers are on the FastClick element (issue #111).
if (event.targetTouches.length > 1) {
    return true;
}
2、获取当前事件点击的节点,如果当前节点是文本,则取其父节点
FastClick.prototype.getTargetElementFromEventTarget = function(eventTarget) {
    // On some older browsers (notably Safari on iOS 4.1 - see issue #56) the event target may be a text node.
    if (eventTarget.nodeType === Node.TEXT_NODE) {
        return eventTarget.parentNode;
    }
    return eventTarget;
};
var targetElement, touch, selection;
targetElement = this.getTargetElementFromEventTarget(event.target);
touch = event.targetTouches[0];
3、如果在IOS上,有选择内容,则忽略;在IOS非4版本上,只有在当前touch.identifier与上一次的相等时才认为是页面alert或confirm之后用户下一次点击页面上的任何其他地方时触发的,此时阻止默认事件,并返回
if (deviceIsIOS) {
    // Only trusted events will deselect text on iOS (issue #49)
    selection = window.getSelection();
    if (selection.rangeCount && !selection.isCollapsed) {
        return true;
    }
    if (!deviceIsIOS4) {
        // Weird things happen on iOS when an alert or confirm dialog is opened from a click event callback (issue #23):
        // when the user next taps anywhere else on the page, new touchstart and touchend events are dispatched
        // with the same identifier as the touch event that previously triggered the click that triggered the alert.
        // Sadly, there is an issue on iOS 4 that causes some normal touch events to have the same identifier as an
        // immediately preceeding touch event (issue #52), so this fix is unavailable on that platform.
        // Issue 120: touch.identifier is 0 when Chrome dev tools 'Emulate touch events' is set with an iOS device UA string,
        // which causes all touch events to be ignored. As this block only applies to iOS, and iOS identifiers are always long,
        // random integers, it's safe to to continue if the identifier is 0 here.
        if (touch.identifier && touch.identifier === this.lastTouchIdentifier) {
            event.preventDefault();
            return false;
        }
        this.lastTouchIdentifier = touch.identifier;

        // If the target element is a child of a scrollable layer (using -webkit-overflow-scrolling: touch) and:
        // 1) the user does a fling scroll on the scrollable layer
        // 2) the user stops the fling scroll with another tap
        // then the event.target of the last 'touchend' event will be the element that was under the user's finger
        // when the fling scroll was started, causing FastClick to send a click event to that layer - unless a check
        // is made to ensure that a parent layer was not scrolled before sending a synthetic click (issue #42).
        this.updateScrollParent(targetElement);
    }
}

这里的updateScrollParent主要用于记录有滚动的父节点与其scrollTop

4、初始化属性
this.trackingClick = true;
this.trackingClickStart = event.timeStamp;
this.targetElement = targetElement;
this.touchStartX = touch.pageX;
this.touchStartY = touch.pageY;
// Prevent phantom clicks on fast double-tap (issue #36)
if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
    event.preventDefault();
}
return true

2、touchmove

layer.addEventListener('touchmove', this.onTouchMove, false);

它调用了onTouchMove函数:

FastClick.prototype.onTouchMove = function(event) {
    if (!this.trackingClick) {
        return true;
    }
    // If the touch has moved, cancel the click tracking
    if (this.targetElement !== this.getTargetElementFromEventTarget(event.target) || this.touchHasMoved(event)) {
        this.trackingClick = false;
        this.targetElement = null;
    }

    return true;
};

这里如果没有执行到touchstart最后的初始化属性,则忽略;如果当前触发事件的节点与记录的触发事件的节点不是一个,则设置trackingClick = false,targetElement = null

3、touchend

layer.addEventListener('touchend', this.onTouchEnd, false);

它调用了onTouchEnd事件:

1、如果trackingClick=false,则忽略
var forElement, trackingClickStart, targetTagName, scrollParent, touch, targetElement = this.targetElement;
if (!this.trackingClick) {
    return true;
}
2、如果事件与上次记录的事件时间小于200ms或大于700ms,则忽略
// Prevent phantom clicks on fast double-tap (issue #36)
if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
    this.cancelNextClick = true;
    return true;
}
if ((event.timeStamp - this.trackingClickStart) > this.tapTimeout) {
    return true;
}
3、设置属性
// Reset to prevent wrong click cancel on input (issue #156).
this.cancelNextClick = false;

this.lastClickTime = event.timeStamp;

trackingClickStart = this.trackingClickStart;
this.trackingClick = false;
this.trackingClickStart = 0;
4、在IOS 6、7版本上执行transition或scroll时,拿到的targetElement无效,此时获取当前文档上处于指定坐标位置最顶层的元素, 坐标是相对于包含该文档的浏览器窗口的左上角为原点来计算的。
// On some iOS devices, the targetElement supplied with the event is invalid if the layer
// is performing a transition or scroll, and has to be re-detected manually. Note that
// for this to function correctly, it must be called *after* the event target is checked!
// See issue #57; also filed as rdar://13048589 .
if (deviceIsIOSWithBadTarget) {
    touch = event.changedTouches[0];
    // In certain cases arguments of elementFromPoint can be negative, so prevent setting targetElement to null
    targetElement = document.elementFromPoint(touch.pageX - window.pageXOffset, touch.pageY - window.pageYOffset) || targetElement;
    targetElement.fastClickScrollParent = this.targetElement.fastClickScrollParent;
}
5、如果当前触发事件的元素标签是label,则获取label标签下的button, input:not([type=hidden]), keygen, meter, output, progress, select, textarea等元素,存在则获取焦点;否则判断当前元素是否需要获取焦点,即textareainput标签type为非button、checkbox、file、image、radio、submit且非disabled非readOnly的其他类型时,获取焦点,同时发送click事件到具体的元素。最后返回
targetTagName = targetElement.tagName.toLowerCase();
if (targetTagName === 'label') {
    forElement = this.findControl(targetElement);
    if (forElement) {
        this.focus(targetElement);
        if (deviceIsAndroid) {
            return false;
        }

        targetElement = forElement;
    }
} else if (this.needsFocus(targetElement)) {

    // Case 1: If the touch started a while ago (best guess is 100ms based on tests for issue #36) then focus will be triggered anyway. Return early and unset the target element reference so that the subsequent click will be allowed through.
    // Case 2: Without this exception for input elements tapped when the document is contained in an iframe, then any inputted text won't be visible even though the value attribute is updated as the user types (issue #37).
    if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
        this.targetElement = null;
        return false;
    }

    this.focus(targetElement);
    this.sendClick(targetElement, event);

    // Select elements need the event to go through on iOS 4, otherwise the selector menu won't open.
    // Also this breaks opening selects when VoiceOver is active on iOS6, iOS7 (and possibly others)
    if (!deviceIsIOS || targetTagName !== 'select') {
        this.targetElement = null;
        event.preventDefault();
    }

    return false;
}
5、最后,IOS且非4版本如果在滚动,则忽略,其他情况判断在不需要点击的情况,阻止其默认行为,同时发送点击事件,返回
if (deviceIsIOS && !deviceIsIOS4) {
    // Don't send a synthetic click event if the target element is contained within a parent layer that was scrolled
    // and this tap is being used to stop the scrolling (usually initiated by a fling - issue #42).
    scrollParent = targetElement.fastClickScrollParent;
    if (scrollParent && scrollParent.fastClickLastScrollTop !== scrollParent.scrollTop) {
        return true;
    }
}
// Prevent the actual click from going though - unless the target node is marked as requiring
// real clicks or if it is in the whitelist in which case only non-programmatic clicks are permitted.
if (!this.needsClick(targetElement)) {
    event.preventDefault();
    this.sendClick(targetElement, event);
}
return false;
6、这里我们在看一下sendClick做了什么?
FastClick.prototype.sendClick = function(targetElement, event) {
    var clickEvent, touch;
    // On some Android devices activeElement needs to be blurred otherwise the synthetic click will have no effect (#24)
    if (document.activeElement && document.activeElement !== targetElement) {
        document.activeElement.blur();
    }
    touch = event.changedTouches[0];
    // Synthesise a click event, with an extra attribute so it can be tracked
    clickEvent = document.createEvent('MouseEvents');
    clickEvent.initMouseEvent(this.determineEventType(targetElement), true, true, window, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, false, false, false, false, 0, null);
    clickEvent.forwardedTouchEvent = true;
    targetElement.dispatchEvent(clickEvent);
};
FastClick.prototype.determineEventType = function(targetElement) {
    //Issue #159: Android Chrome Select Box does not open with a synthetic click event
    if (deviceIsAndroid && targetElement.tagName.toLowerCase() === 'select') {
        return 'mousedown';
    }
    return 'click';
};

它主要是创建事件,并触发。

4、touchcancel

layer.addEventListener('touchcancel', this.onTouchCancel, false);

它的内容比较简单,如下:

FastClick.prototype.onTouchCancel = function() {
    this.trackingClick = false;
    this.targetElement = null;
};

5、click

layer.addEventListener('click', this.onClick, true);

它调用onClick函数:

FastClick.prototype.onClick = function(event) {
    var permitted;
    // It's possible for another FastClick-like library delivered with third-party code to fire a click event before FastClick does (issue #44). In that case, set the click-tracking flag back to false and return early. This will cause onTouchEnd to return early.
    if (this.trackingClick) {
        this.targetElement = null;
        this.trackingClick = false;
        return true;
    }
    // Very odd behaviour on iOS (issue #18): if a submit element is present inside a form and the user hits enter in the iOS simulator or clicks the Go button on the pop-up OS keyboard the a kind of 'fake' click event will be triggered with the submit-type input element as the target.
    if (event.target.type === 'submit' && event.detail === 0) {
        return true;
    }
    permitted = this.onMouse(event);
    // Only unset targetElement if the click is not permitted. This will ensure that the check for !targetElement in onMouse fails and the browser's click doesn't go through.
    if (!permitted) {
        this.targetElement = null;
    }
    // If clicks are permitted, return true for the action to go through.
    return permitted;
};

五、总结

1、可以看到FastClick主要是针对怪异现象做hack处理。
2、在事件监听回调中,我们看到很多return false与return true,其实是无意义的,只是阻止了程序往下执行而已;
可能有些同学认为return false是为了阻止其默认行为,根据HTML5 Section 6.1.5.1 of the HTML Spec规范,事件处理程序的返回值只对通过属性注册的处理程序才有意义,如果我们未通过addEventListener()函数来绑定事件的话,若要禁止默认事件,用的就是return false; 但如果要用addEventListener()或者attachEvent()来绑定,就要用preventDefault()方法或者设置事件对象的returnValue属性;在规范中有指出在mouseover等几种特殊事件情况下,return false;并不一定能终止事件。所以,在实际使用中,我们需要尽量避免通过return false;的方式来取消事件的默认行为;所以在FastClick使用addEventListener监听事件,return true/false没有什么特殊的作用。
3、它消除了移动端上用户点击后有300-350ms的延时,主要是在touchend时通过自定义click事件并触发实现的。

六、遇到的坑

1、加入了FastClick,在IOS上点击input标签会发现有时很难聚焦

在我们处理touchend监听的时候也处理focus事件:

if (this.needsFocus(targetElement)) {
    // Case 1: If the touch started a while ago (best guess is 100ms based on tests for issue #36) then focus will be triggered anyway. Return early and unset the target element reference so that the subsequent click will be allowed through.
    // Case 2: Without this exception for input elements tapped when the document is contained in an iframe, then any inputted text won't be visible even though the value attribute is updated as the user types (issue #37).
    if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
        this.targetElement = null;
        return false;
    }
    this.focus(targetElement);
    this.sendClick(targetElement, event);
    // Select elements need the event to go through on iOS 4, otherwise the selector menu won't open.
    // Also this breaks opening selects when VoiceOver is active on iOS6, iOS7 (and possibly others)
    if (!deviceIsIOS || targetTagName !== 'select') {
        this.targetElement = null;
        event.preventDefault();
    }
    return false;
}

我们的input事件之所以不能聚焦,是因为在focus后面判断
targetTagName !== 'select'时调用event.preventDefault()阻止了默认行为;虽然我们去掉这里阻止的默认行为可以解决,但同样的,有这段代码也是为了针对部分手机的怪异行为做的hack处理,所以我们看看focus方法有没有可以优化的地方。focus方法内容如下:

FastClick.prototype.focus = function(targetElement) {
    var length;
    // Issue #160: on iOS 7, some input elements (e.g. date datetime month) throw a vague TypeError on setSelectionRange. These elements don't have an integer value for the selectionStart and selectionEnd properties, but unfortunately that can't be used for detection because accessing the properties also throws a TypeError. Just check the type instead. Filed as Apple bug #15122724.
    if (deviceIsIOS && targetElement.setSelectionRange && targetElement.type.indexOf('date') !== 0 && targetElement.type !== 'time' && targetElement.type !== 'month') {
        length = targetElement.value.length;
        targetElement.setSelectionRange(length, length);
    } else {
        targetElement.focus();
    }
};

在IOS 7上一些input标签type为date | time | month的时候在设置选中的范围时通常会报错,这里针对input其他类型时使用setSelectionRange来设定光标,同时将光标定在输入框的最后。这里我们并没有做任何的聚焦处理,所以使用了默认行为,但由于后面我们阻止了默认行为,所以针对这种情况我们也应该做手动聚焦处理,将focus方法修改为:

FastClick.prototype.focus = function(targetElement) {
    var length;
    if (deviceIsIOS && targetElement.setSelectionRange && targetElement.type.indexOf('date') !== 0 && targetElement.type !== 'time' && targetElement.type !== 'month') {
        length = targetElement.value.length;
        targetElement.setSelectionRange(length, length);
        targetElement.focus();
    } else {
        targetElement.focus();
    }
};

这里由于我们调用了setSelectionRange(length, length),点击的时候光标始终会放在文末,如果不需要也可直接去掉:

FastClick.prototype.focus = function(targetElement) {
    targetElement.focus();
};

参考文档:
https://www.cnblogs.com/momo7...
https://blog.csdn.net/a460550...
知识点:
https://developers.google.com...
https://developer.mozilla.org...
https://developer.mozilla.org...
https://developer.mozilla.org...
https://developer.mozilla.org...
https://developer.mozilla.org...
https://developer.mozilla.org...
https://developer.mozilla.org...
https://developer.mozilla.org...

有兴趣可关注公众号
qrcode_for_gh_b57a7204ca03_258.jpg

查看原文

赞 0 收藏 0 评论 0

花伊浓 发布了文章 · 2020-04-01

关于移动端适配

关于移动端适配,网上已经有很多文章了,这篇文章主要是整理总结一下自己的理解,如有问题,欢迎指出。

一、概念

在了解移动端适配时,有几个概念经常出现在移动端适配文章中,这个需要知道的。

1、设备像素(device pixels)

设备像素可以理解为设备屏幕的大小,它是屏幕的属性而非浏览器的,无论用户是缩小或放大网页,它的大小始终不会改变,可以通过screen.width/height获取。

2、CSS像素(CSS pixels)

CSS像素是我们开发更关注的一个抽象的概念,它是一个相对的设备像素单位。

其实可以理解为我们常说的手机分辨率。

在标准的设备上,设备像素与CSS像素的比值(设备像素比)为1:1,表示1pxCSS像素对应1px设备像素;在Retina屏上,当设备像素比为2时,1pxCSS像素对应4px(2*2)设备像素;当设备像素比为3时,1pxCSS像素对应9px(3*3)设备像素。
retina-pixel.png

默认情况下,CSS像素占比取决于设备像素比,但当页面缩小放大时,会影响CSS像素占的空间;假设当前1个CSS像素等于1个物理像素:

浏览器此时的宽度为800px,页面时同时有一个400px宽的块级元素容器,此时块状容器占页面的一半。

如果我们放大页面,放大为200%,即原来的两倍,此时块状容器则横向占满了整个浏览器。

我们并没有跳转浏览器窗口的大小,也没有改变块状元素的CSS宽度,但它看起来却变大了一倍,这是因为我们把CSS像素方法为了原来的两倍。此时的1个CSS像素等于了2个设备像素

3、设备独立像素DIP(Device independent pixels)

设备独立像素,也称逻辑像素,它其实就是CSS像素,即:

CSS像素 = 设备独立像素 = 逻辑像素

4、设备像素比DPR(Device pixels ratio)

设备像素比(dpr)是指在移动开发中1个CSS像素占用多少设备像素,它描述的是物理像素与CSS像素的初始比值关系,如2代表1个CSS像素用2 * 2个设备像素来绘制。
设备像素比可以通过window.devicePixelRatio获取

5、两个视口:布局视口(Layout Viewport)与可视视口(Visual Viewport)

George Cummins explains the basic concept besthereat Stack Overflow:

Imagine the layout viewport as being a large image which does not change size or shape. Now image you have a smaller frame through which you look at the large image. The small frame is surrounded by opaque material which obscures your view of all but a portion of the large image. The portion of the large image that you can see through the frame is the visual viewport. You can back away from the large image while holding your frame (zoom out) to see the entire image at once, or you can move closer (zoom in) to see only a portion. You can also change the orientation of the frame, but the size and shape of the large image (layout viewport) never changes.

上面这段话解析得非常清晰,将内容视为一张大图,布局视口是包含整个大图得内容,而可视视口只是用户可以看到得一部分,在手机上,它的大小就是屏幕的大小。
布局视口可以通过document.documentElement.clientWidth-Height获取
可视视口可以通过window.innerWidth/Height获取

5、理想视口(Ideal Viewport)

除了布局视口、可视视口,还有第三种视口:理想视口(ideal viewport),它的出现是因为在移动端,默认视口是布局视口,它可能比可视视口大,这样页面就会出现滚动条,用户阅读的时候可能需要缩放。而理想视口是指完美适配移动端设备,不需要用户缩放和横向滚动条就能正常的查看网站的所有内容,且无论在什么设备上显示的文字的大小是合适,也包括图片。
理想视口其实就是等于可视视口,即设备屏幕大小。

6、媒体查询

width/height是用布局视口作为参考,以CSS像素衡量
device-width/height是用设备屏幕作为参考,以设备像素衡量

在开发移动端项目时,经常会在html头部见到这么一个标签:

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">

它主要是将当前的viewport设置为屏幕的大小,同时不允许用户缩放。它一共有6个属性:

属性解释
width设置布局视口的宽度,可以设置为device-width表示设备宽度
initial-scale设置页面初始缩放值与布局视口宽度
minimum-scale设置用户可缩放的最小值
maximum-scale设置用户可缩放的最大值
height设置布局视口的高度,不过基本上不会需要用到
user-scalable设置是否允许用户缩放,no(不允许)/yes(允许)

单独设置width=device-widthinitial-scale=1都可以将布局视口设置等于屏幕大小,即理想视口,设置初始缩放能达到同样的效果是因为它是相对于理想视口缩放的;虽然单独使用它们任何一个都可以达到同样的效果,但这两者各有一个小缺陷,就是iphone、ipad以及IE 会横竖屏不分,通通以竖屏的ideal viewport宽度为准。所以,最完美的写法应该是,两者都写上去,这样就 initial-scale=1 解决了 iphone、ipad的毛病,width=device-width则解决了IE的毛病。

如果同时设置这两个属性而width等于其他值,比如400时,initial-scale=1,这种情况浏览器会取值比较大的一个。

关于缩放与initial-scale的初始值(有些兼容问题,细节可查看参考链接3:8.4.1),这里只是作为了解,缩放是相对于理想视口缩放的,initial-scale=2时实际上是将1px变得跟原来的2px的长度一样了,所以如果设备的宽度为320px,设置了initial-scale=2不会变成640px,而是160px。可以得到以下公式:

visual viewport宽度 = ideal viewport宽度 / 当前缩放值
当前缩放值 = ideal viewport宽度 / visual viewport宽度

由于各浏览器默认的layout viewport宽度一般都是980、1024、800等等,没有一开始就是ideal viewport的宽度的,所以initial-scale的默认值肯定不是1。

二、移动端适配:rem

rem是相对于html的font-size的单位。
我们使用rem适配通常有如下几个步骤:
1、写样式都是按照设计稿去写的,通常设计稿的宽度是750px,此时若我们设置基数为100(即html的font-size为100px),这时1rem表示100px;
2、而在我们的页面中,我们通常会通过视口的宽度去设置html的font-size,此时若我们设置为50px,这时1rem表示50px
100px变50px,很好理解,这就已经实现将750px的设计稿缩放在宽度为375px设备上了;当我们针对不同设备的不同宽度去设置html的font-size时,就很好的适配了移动端。

总结,通过viewport的meta标签将可视视口的宽度设置为设备像素的宽度即屏幕大小,保证页面无横向滚动条,利用rem,将宽为750px的设计稿缩放在不同宽度的设备上。

参考链接:
1、https://www.quirksmode.org/mo...
2、https://www.quirksmode.org/mo...
3、https://github.com/jawil/blog...

查看原文

赞 3 收藏 3 评论 0

花伊浓 发布了文章 · 2020-03-27

H5物理返回键处理逻辑梳理

做过移动端的同学都知道,手机的物理返回键不好控制,这里梳理了一下关于物理返回键针对不同场景的处理。

一、概述

物理返回处理分为两类,一类是当前APP提供我们监听物理键的返回事件,一类没有;针对第一种,就无需考虑了,这里我们主要讲解第二种。

HTML5提供我们两个事件可以监听物理返回:popState、hashChange,这里主要用popState。

首先,我们来了解一下popSate,在MDN文档是这么说的:

当活动历史记录条目更改时,将触发popstate事件。如果被激活的历史记录条目是通过对history.pushState()的调用创建的,或者受到对history.replaceState()的调用的影响,popstate事件的state属性包含历史条目的状态对象的副本。

需要注意的是调用history.pushState()history.replaceState()不会触发popstate事件。只有在做出浏览器动作时,才会触发该事件,如用户点击浏览器的回退按钮(或者在Javascript代码中调用history.back()或者history.forward()方法)

不同的浏览器在加载页面时处理popstate事件的形式存在差异。页面加载时Chrome和Safari通常会触发(emit )popstate事件,但Firefox则不会。

以上就是popSate的触发时机。

二、物理返回处理

目前物理返回我只碰到过4种情况,后续有新增的再更新文档。由于我们的项目是单页应用,这里也以单页应用为例。

1、当前路由返回需要跳转到别的页面

针对这种情况,有明确的下一个页面的路由,这时只需要用router.push/replace直接跳转即可

2、返回上一页

由于我们的物理返回本来就是会返回上一页的,所以针对这种情况其实也什么都不用处理

3、第一次返回需要弹窗提示用户,当前不返回,第二次返回时才真正返回

由于物理返回始终要返回一次的,而这里我们不让放回,那何不直接将当前的链接再放入历史记录呢,这样就算返回了,我们还在当前页面,这里我们调用:history.pushState(null, null, currentUrl),由于pushState不用触发popState事件,所以这里无需担心popState还会触发一次。

需要注意的是,在popState的回调函数中调用window.location.href已经是返回后的链接了,如果要获取返回之前的,建议调用 router.currentRoute获取,该值的改变在popState事件之后;这里我们的currentUrl = '#' + router.currentRoute.fullPath

4、不同项目之间相互跳转时,当回到某个项目首页时,由于进入该项目后来回跳转了,所以此时在首页想要返回到进入该项目之前的页面

由于来回跳转,所以直接返回肯定是返回不回去的,这个时候需要记录一下进入该项目的时候的history.length,始终保持history.length最小,当在项目首页要返回进入该项目的页面是,用当前的history.length与之前记录的历史长度recordedHistoryLength做比较,如果history.length > recordedHistoryLength,则调用history.go(recordedHistoryLength - history.length -1)返回

三、注意

最后,还有一点需要注意的,因为我们的单页面应用除了物理返回还有正常路由的跳转,由于vue-router的mode默认值为hash,当执行路由跳转时比如push、replace最终会调用vue-router文件中的pushHashreplaceHash,该方法有两种执行方式:

1、只有在支持pushState的情况下才会调用history的pushState/replaceState
2、否则使用window.location.hash/window.location.replace进行跳转

针对这两种执行方式,这里分两种情况:

1、提前打包vue-router成lib
由于打包的时候是属于node环境,没有window,window并没有history属性,更没有pushState,所以会走第二种执行方式,这种情况下是会触发popState事件,此时我们可以在router的beforeEach事件回调中执行时存储一个变量,表示当前为普通路由跳转,因为普通路由跳转的执行机制会先触发router的beforeEach,再执行history的方法跳转,想要深入了解的同学可以去看一下vue-router的源码,所以这里,需要在popState事件中加上判断,如果刚刚执行过beforeEach了,则这次url改变不执行popState回调中调用的方法

2、如果没有提取打包成lib,直接引用
这种情况会执行第一种方式,由于执行pushState/replaceState是不触发popState事件的,此时无需添加多余的逻辑

参考资料:
https://developer.mozilla.org...

查看原文

赞 0 收藏 0 评论 0

花伊浓 发布了文章 · 2019-10-22

【面试题】['1', '2', '3'].map(parseInt)输出结果?

很多人在面试中可能都有见过这道题,它的答案是[1, NaN, NaN],为什么呢?

首先我们看一下map函数的参数定义:

array.map(function(currentValue,index,arr), thisValue)

具体含义就不说了,再看看parseInt函数参数定义:

parseInt(string, radix)

很多人都用过parseInt,但是基本上却很少用它的第二个参数,在w3school是这么定义的:

表示要解析的数字的基数。该值介于 2 ~ 36 之间。
如果省略该参数或其值为 0,则数字将以 10 为基础来解析。如果它以 “0x” 或 “0X” 开头,将以 16 为基数。
如果该参数小于 2 或者大于 36,则 parseInt() 将返回 NaN。`

在我们这道题中,很明显,它的返回值实际上就是:

[
    parseInt('1', 0),
    parseInt('2', 1),
    parseInt('3', 2)
]

从它的定义来看,由于第一个传入的第二个参数是0,所以它以10为基础来解析,所以返回数字 1 本身
第二个传入的第二个参数是1,小于2,所以返回的NaN
第三个传入的第二个参数是2,由于我们的二进制只有0、1,所以当我们传入字符串3时无法识别,所以还是返回的NaN

以上

参考资料:
parseInt: https://www.w3school.com.cn/j...
map: https://www.cnblogs.com/zhaox...

查看原文

赞 1 收藏 0 评论 0

花伊浓 发布了文章 · 2019-10-14

style-loader源码解析

首先打开style-loader的package.json,找到main,可以看到它的入口文件即为:dist/index.js,内容如下:`

var _path = _interopRequireDefault(require("path"));
var _loaderUtils = _interopRequireDefault(require("loader-utils"));
var _schemaUtils = _interopRequireDefault(require("schema-utils"));
var _options = _interopRequireDefault(require("./options.json"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
module.exports = () => {};
module.exports.pitch = function loader(request) {
    // ...
}

`其中_interopRequireDefault的作用是:如果引入的是 es6 模块,直接返回,如果是 commonjs 模块,则将引入的内容放在一个对象的 default 属性上,然后返回这个对象。
我首先来看pitch函数,它的内容如下:`

// 获取webpack配置的options
const options = _loaderUtils.default.getOptions(this) || {};
// (0, func)(),运用逗号操作符,将func的this指向了windows,详情请查看:https://www.jianshu.com/p/cd188bda72df
// 调用_schemaUtils是为了校验options,知道其作用就行,这里就不讨论了
(0, _schemaUtils.default)(_options.default, options, {
    name: 'Style Loader',
    baseDataPath: 'options'
});
// 定义了两个变量,**insert**、**injectType**,不难看出insert的默认值为head,injectType默认值为styleTag
const insert = typeof options.insert === 'undefined' ? '"head"' : typeof options.insert === 'string' ? JSON.stringify(options.insert) : options.insert.toString();
const injectType = options.injectType || 'styleTag';
switch(injectType){
    case 'linkTag':
        {
            // ...
        }
    case 'lazyStyleTag':
    case 'lazySingletonStyleTag':
        {
            // ...
        }
    case 'styleTag':
    case 'singletonStyleTag':
    default:
        {
            // ...
        }
}`

在这里,我们就看默认的就好了,即insert=head,injectType=styleTag`

const isSingleton = injectType === 'singletonStyleTag';
const hmrCode = this.hot ? `
        // ...
    ` : '';
return `
    // _loaderUtils.default.stringifyRequest这里就不叙述了,主要作用是将绝对路径转换为相对路径
    var content = require(${_loaderUtils.default.stringifyRequest(this, `!!${request}`)});
    if (typeof content === 'string') {
        content = [[module.id, content, '']];
    }
    var options = ${JSON.stringify(options)}
    options.insert = ${insert};
    options.singleton = ${isSingleton};
    
    var update = require(${_loaderUtils.default.stringifyRequest(this, `!${_path.default.join(__dirname, 'runtime/injectStylesIntoStyleTag.js')}`)})(content, options);
    if (content.locals) {
        module.exports = content.locals;
    }
    ${hmrCode}
`;`

去掉多余的代码,可以清晰的看到pitch方法实际上最后返回了一个字符串,该字符串就是编译后在浏览器执行的代码,让我们来看看它在浏览器是如何操作的:
首先调用require方法获取css文件的内容,将其赋值给content,如果content是字符串,则将content赋值为数组,即:[[module.id], content, ''],接着我们覆盖了options的insert、singleton属性,由于我们暂时只看默认的,所以insert=head,singleton=false;再往下面看,我们又使用require方法引用了runtime/injectStyleIntoStyleTag.js,它返回一个函数,我们将content和options传递给该函数,并立即执行它:`

module.exports = function (list, options) {
    options = options || {};
    options.attributes = typeof options.attributes === 'object' ? options.attributes : {}; // Force single-tag solution on IE6-9, which has a hard limit on the # of <style>
    // tags it will allow on a page
    if (!options.singleton && typeof options.singleton !== 'boolean') {
        options.singleton = isOldIE();
    }
    
    var styles = listToStyles(list, options);
    addStylesToDom(styles, options);
    return function update(newList) {
        // ...
    };
};

可以看到,该函数的主要内容即为

var styles = listToStyles(list, options);
addStylesToDom(styles, options);

我们先来看看listToStyles做了什么

function listToStyles(list, options) {
    var styles = [];
    var newStyles = {};

    for (var i = 0; i < list.length; i++) {
        var item = list[i];
        // 回过头去看就知道,item实际上等于[[module.id, content, '']],其中content即为css文件的内容
        var id = options.base ? item[0] + options.base : item[0];
        var css = item[1];
        var media = item[2]; // ''
        var sourceMap = item[3]; // undefined
        var part = {
            css: css,
            media: media,
            sourceMap: sourceMap
        };

        if (!newStyles[id]) {
            styles.push(newStyles[id] = {
                id: id,
                parts: [part]
            });
        } else {
            newStyles[id].parts.push(part);
        }
    }

    return styles;
}

这段代码很简单,将传递进来的内容转换为了styles数组,接下来看看addStylesToDom函数:

// 在文件顶部,定义了stylesInDom对象,主要是用来记录已经被加入DOM中的styles
var stylesInDom = {};
function addStylesToDom(styles, options) {
    for (var i = 0; i < styles.length; i++) {
        var item = styles[i];
        var domStyle = stylesInDom[item.id];
        var j = 0;
        // 判断当前style是否加入DOM中
        if (domStyle) {
            domStyle.refs++;
            // 如果加入,首先循环已加入DOM的parts,并调用其函数,这里我们比较疑惑,但是往下看两行我们就知道这个函数从哪儿来了
            for (; j < domStyle.parts.length; j++) {
                domStyle.parts[j](item.parts[j]);
            }
            // 除了上面循环的,如果传进来的style还有则说明又新增的,调用addStyle方法并将其返回值放入domStyle的parts中
            // 这里就知道了parts中存放的是addStyle,且是一个函数
            for (; j < item.parts.length; j++) {
                domStyle.parts.push(addStyle(item.parts[j], options));
            }
        } else {
            // 如果没有加入DOM中,则依次调用addStyle并存入数组parts中,并将当前的style存入stylesInDom对象中
            var parts = [];

            for (; j < item.parts.length; j++) {
                parts.push(addStyle(item.parts[j], options));
            }

            stylesInDom[item.id] = {
                id: item.id,
                refs: 1,
                parts: parts
            };
        }
    }
}

其中的关键还是在于addStyle函数

var singleton = null;
var singletonCounter = 0;

function addStyle(obj, options) {
    var style;
    var update;
    var remove;
    // 默认singleton为false,所以暂时不考虑if的内容了
    if (options.singleton) {
        var styleIndex = singletonCounter++;
        style = singleton || (singleton = insertStyleElement(options));
        update = applyToSingletonTag.bind(null, style, styleIndex, false);
        remove = applyToSingletonTag.bind(null, style, styleIndex, true);
    } else {
        style = insertStyleElement(options);
        update = applyToTag.bind(null, style, options);

        remove = function remove() {
            removeStyleElement(style);
        };
    }

    update(obj);
    return function updateStyle(newObj) {
        if (newObj) {
            if (newObj.css === obj.css && newObj.media === obj.media && newObj.sourceMap === obj.sourceMap) {
                return;
            }

            update(obj = newObj);
        } else {
            remove();
        }
    };
}

可以看到它返回一个函数,其主要内容是判断传入的对象是否与原对象相等,如果相等,则什么都不做,否则调用update函数,如果对象为空,则调用remove函数。而update与remove是在else中被赋值的,在赋值之前,我们首先看insertStyleElement函数:

var getTarget = function getTarget() {
    var memo = {};
    return function memorize(target) {
        if (typeof memo[target] === 'undefined') {
            var styleTarget = document.querySelector(target); // Special case to return head of iframe instead of iframe itself

            if (window.HTMLIFrameElement && styleTarget instanceof window.HTMLIFrameElement) {
                try {
                    // This will throw an exception if access to iframe is blocked
                    // due to cross-origin restrictions
                    styleTarget = styleTarget.contentDocument.head;
                } catch (e) {
                    // istanbul ignore next
                    styleTarget = null;
                }
            }

            memo[target] = styleTarget;
        }

        return memo[target];
    };
}();
function insertStyleElement(options) {
    var style = document.createElement('style');

    if (typeof options.attributes.nonce === 'undefined') {
        var nonce = typeof __webpack_nonce__ !== 'undefined' ? __webpack_nonce__ : null;

        if (nonce) {
            options.attributes.nonce = nonce;
        }
    }

    Object.keys(options.attributes).forEach(function (key) {
        style.setAttribute(key, options.attributes[key]);
    });

    if (typeof options.insert === 'function') {
            options.insert(style);
    } else {
        var target = getTarget(options.insert || 'head');

        if (!target) {
            throw new Error("Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid.");
        }

        target.appendChild(style);
    }

    return style;
}

上面函数很简单,创建一个style标签,并将其插入insert中,即head中,回到之前的地方,我们定义了update和remove,之后我们手动调用update函数,即applyToTag

function applyToTag(style, options, obj) {
    var css = obj.css;
    var media = obj.media;
    var sourceMap = obj.sourceMap;

    if (media) {
        style.setAttribute('media', media);
    }

    if (sourceMap && btoa) {
        css += "\n/*# sourceMappingURL=data:application/json;base64,".concat(btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap)))), " */");
    } // For old IE

    /* istanbul ignore if  */


    if (style.styleSheet) {
        style.styleSheet.cssText = css;
    } else {
        while (style.firstChild) {
            style.removeChild(style.firstChild);
        }

        style.appendChild(document.createTextNode(css));
    }
}

这段代码很简单,即给刚创建的style标签更新内容,而remove函数指向removeStyleElement函数

function removeStyleElement(style) {
    // istanbul ignore if
    if (style.parentNode === null) {
        return false;
    }

    style.parentNode.removeChild(style);
}

`即删除styleDOM结构

总结一下,style-loader会返回一个字符串,而在浏览器中调用时,会将创建一个style标签,将其加入head中,并将css的内容放入style中,同时每次该文件更新也会相应的更新Style结构,如果该css文件内容被删除,则style的内容也会被相应的删除,总体来说,style-loader做了一件非常简单的事:在 DOM 里插入一个 <style> 标签,并且将 CSS 写入这个标签内`

const style = document.createElement('style'); // 新建一个 style 标签 
style.type = 'text/css’;   
style.appendChild(document.createTextNode(content)) // CSS 写入 style 标签 
document.head.appendChild(style); // style 标签插入 head 中

`

查看原文

赞 1 收藏 0 评论 0

花伊浓 发布了文章 · 2019-03-22

Service Worker 基本用法

看了很多介绍Service Worker的,看得都挺模糊的,所以决定自己写一篇文件整理一下思路。

一、Service Worker API 名词区分

1、ServiceWorkerContainer:navigator.serviceWorker返回的就是Service WorkerContainer对象,主要是用户在页面注册serviceWorker,调用方法:

navigator.serviceWorker.register(scriptURL, options)
    .then(function(ServiceWorkerRegistration) { ... })

2、ServiceWokrerGlobalScope:主要是用户在sw.js文件的全局变量,即this的指向
3、ServiceWorkerRegistration:在页面调用serviceWorker.register注册返回一个Promise对象,当resolve时传递给then的函数参数就是ServiceWorkerRegistration.
4、ServiceWorker:表示ServiceWorkerRegistration.installing || ServiceWorkerRegistration.waiting || ServiceWorkerRegistration.active

二、Service Worker 注意事项

1、建立在HTTPS上;
2、不支持缓存POST请求;

三、Service Worker 注册

1、index.html

<script>
    // register
    if("serviceWorker" in navigator){
        navigator.serviceWorker.register('./sw.js')
            .then(function(registration){
                console.log("Register success: ",registration.scope);
            })
            .catch(function(err){
                console.log("Register failed: ",err);
            });
    }else{
        console.log('Service workers are not supported.');
    }
</script>

2、sw.js

var CACHE_NAME = 'sw-test-v1';
this.addEventListener('install',function(event){
    console.log("installing...");
    event.waitUntil(caches.open(CACHE_NAME).then(function(cache){
        cache.addAll([
            'images/resource01.jpg',
            'images/resource02.jpg',
            ....
        ]);
    }));
});
this.addEventListener('fetch', function(event) {
    event.respondWith(
        caches.match(event.request)
            .then(function(response) {
                if (response) { // 缓存命中,返回缓存
                    return response;
                }
                // 请求是stream数据,只能使用一次,所以需要拷贝,一次用于缓存,一次用于浏览器请求
                var fetchRequest = event.request.clone();
                return fetch(fetchRequest)
                    .then(function(response) {
                        if(!response || response.status !== 200) {
                            return response;
                        }
                        // 响应也是stream,只能使用一次,一次用于缓存,一次用于浏览器响应
                        var responseToCache = response.clone();
                        caches.open(CACHE_NAME)
                            .then(function(cache) {
                                cache.put(event.request, responseToCache);
                            });
                        return response;
                    });
            })
  );
});

sw.js工作内容:首先监听install事件,调用cache.addAll()方法将静态资源加入缓存中。然后监听fetch事件,判断当前请求的url是否在缓存中,如果在则返回内容,如果不在,则向服务端发起请求数据,将返回的数据放入缓存中并且返回给浏览器。
代码中的方法解析:
1、caches.match():检查给定的Request对象或url字符串是否是一个已存储的 Response对象的key. 该方法针对 Response 返回一个 Promise ,如果没有匹配则返回 undefined 。cache对象按创建顺序查询,等同于在每个缓存上调用 cache.match() 方法 (按照caches.keys()返回的顺序) 直到返回Response 对象。语法如下:

caches.match(request, options).then(function(response) {
  // Do something with the response
});

参数解析:

options: 可选,配置对象中的属性控制在匹配操作中如何进行匹配选择,具体属性如下:

  • ignoreSearch: Boolean值, 指定匹配过程是否应该忽略url中查询参数,默认 false。
  • ignoreMethod: Boolean 值,当被设置为 true 时,将会阻止在匹配操作中对 Request请求的 http 方式的验证 (通常只允许 GET 和 HEAD 两种请求方式)。该参数默认为 false.
  • ignoreVary: Boolean 值,当该字段被设置为 true, 告知在匹配操作中忽略对VARY头信息的匹配。换句话说,当请求 URL 匹配上,你将获取匹配的 Response 对象,无论请求的 VARY 头存在或者没有。该参数默认为 false.
  • cacheName: DOMString 值, 表示所要搜索的缓存名称。

2、caches.open():返回一个resolve为匹配 cacheName 的 cache 对象的 Promise .如果指定的 cache 不存在,则使用该 cacheName 创建一个新的cache并返回。

caches.open(cacheName).then(function(cache) {});

3、cache.addAll():将静态资源加入缓存中

cache.addAll(requests[]).then(function() {
  // 已加入缓存
});

该方法会覆盖掉以前存储在缓存中的匹配的健值对,但是后面监听对fetch事件中调用cache.put()方法又会覆盖掉之前在cache.addAll()中添加到缓存中所匹配的健值对。
4、cache.put():允许将键/值对添加到当前的 Cache 对象中.它将覆盖先前存储在匹配请求的cache中的任何键/值对。
注意: Cache.add/Cache.addAll 不会缓存 Response.status 值不在200范围内的响应,而 Cache.put 允许你存储任何请求/响应对。因此,Cache.add/Cache.addAll 不能用于不透明的响应,而 Cache.put 可以。

cache.put(request, response).then(function() {
  // request/response pair has been added to the cache
});

5、event.waitUntil():扩展了事件的生命周期。在服务工作线程中,延长事件的寿命从而阻止浏览器在事件中的异步操作完成之前终止服务工作线程。
install事件中,它延迟将被安装的worker视为 installing ,直到传递的 Promise 被成功地resolve。主要用于确保:服务工作线程在所有依赖的核心cache被缓存之前都不会被安装。
activate事件中,它延迟将 active worker视为已激活的,直到传递的 Promise 被成功地resolve。这主要用于确保:任何功能事件不会被分派到 ServiceWorkerGlobalScope 对象,直到它升级数据库模式并删除过期的缓存条目。
当该方法运行时,如果 Promise 是resolved,任何事情都不会发生;如果 Promise 是rejected,installing 或者 active worker的 state 会被设置为redundant。
语法:event.waitUntil(promise)
6、event.respondWith():阻止浏览器默认的fetch处理方法,允许用户自己提供一个promise对象作为response返回。

fetchEvent.respondWith(
  // Promise that resolves to a Response.
​)

Parameters:A Promise for a Response.

上面的sw.js只是一个最基本的serviceWorker,在日常工作中,我们还需要考虑更新。

四、Service Worker更新

(一)自动更新

this.addEventListener('install',function(event){
    this.skipWaiting();
});
this.addEventListener('activate', function (event) {
    this.clients.claim();
});

skipWaiting(): 强制等待中的service worker跳过等待成为激活的service worker。虽然该方法在任何时候都是可以调用的,但是只有在新安装的service worker仍然处于等待状态时才会起作用;所以在install事件里面调用是非常常见的。与clients.claim()一起调用以确保更新当前的client和其他激活的clients。
clients.claim(): 允许一个激活的service worker将其设置为其他同scope下的clients的controller。该方法会触发要被该service worker控制的其他任何clients的navigator.serviceWorker上的"controllerchange"事件。
当一个service worker初始注册时并不会使用该service worker,直到下次加载页面时。该方法会让这些页面直接被控制,注意,这将导致你的service worker将控制定期加载的页面,也有可能控制其他service worker加载的页面。

(二)手动更新

手动更新主要是调用在index.html注册serviceWorker时的registration的update()方法:ServiceWorkerRegistration.update();
它会获取worker的脚本URL,如果新的worker与当前的worker并不是完全相同的(byte-by-byte identical)则安装新的worker;如果前一次worker获取发生在24小时之前,则worker的获取将绕过任何浏览器缓存。

navigator.serviceWorker.register('./sw.js').then(function(registration){
   registration.update();
});

参考学习链接:
https://developer.mozilla.org...
https://developers.google.cn/...
https://lavas.baidu.com/pwa/o...
https://segmentfault.com/a/11...

查看原文

赞 2 收藏 0 评论 2

花伊浓 关注了专栏 · 2018-08-14

前端精读专栏

精读前端业界好文,每周更新

关注 6512

花伊浓 关注了专栏 · 2018-08-14

前端每日实战

?该专栏由《CSS3 艺术》一书的作者亲自维护,已累计分享 170+ 个前端项目从灵感闪现到代码实现的完整过程。?

关注 5288

花伊浓 关注了专栏 · 2018-08-14

Jrain-前端玩具盆

记录一路以来的各种折腾。

关注 7139

认证与成就

  • 获得 7 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2018-08-14
个人主页被 451 人浏览