上篇文章《html() 和 innerHTML 的小坑》我们分析了原生 JavaScript 的 element.innerHTMLjQuery 与 zepto.js 的 element.html() 两种方法在处理包含 <script> 标签的字符串时的异同。

本着知其然知其所以然的态度,我们这次从源码分析一下 element.html() 的工作原理,为什么 innerHTML 不能使其中的脚本执行,而 jQuery 的 html() 却可以,而 zepto.js 的只能执行内联脚本却不能加载外部脚本。

 

我们使用 jQuery 3.1.0 和 Zepto.js 1.2.0 的源码进行分析

jQuery Github

Zepto.js Github

 

jQuery

首先看看 html() 方法的主入口:

// jQuery/src/manipulation.js

html: function( value ) {
    return access( this, function( value ) {
        var elem = this[ 0 ] || {},
            i = 0,
            l = this.length;

        if ( value === undefined && elem.nodeType === 1 ) {
            return elem.innerHTML;
        }

        // See if we can take a shortcut and just use innerHTML
        if ( typeof value === "string" && !rnoInnerhtml.test( value ) &&
            !wrapMap[ ( rtagName.exec( value ) || [ "", "" ] )[ 1 ].toLowerCase() ] ) {

            value = jQuery.htmlPrefilter( value );

            try {
                for ( ; i < l; i++ ) {
                    elem = this[ i ] || {};

                    // Remove element nodes and prevent memory leaks
                    if ( elem.nodeType === 1 ) {
                        jQuery.cleanData( getAll( elem, false ) );
                        elem.innerHTML = value;
                    }
                }

                elem = 0;

                // If using innerHTML throws an exception, use the fallback method
            } catch ( e ) {}
        }

        if ( elem ) {
            this.empty().append( value );
        }
    }, null, value, arguments.length );
},

 

html() 方法返回了一个闭包函数,它是一个用于 set/get 一个集合的多功能函数

第 9-11 行,表示直接调用 element.html() 而没有加入任何参数时,直接返回当前元素中的内容

第 14 行的 rnoInnerhtml 在前面有定义,rnoInnerhtml = /<script|<style|<link/i,用于匹配不含有 <script><style><link> 标签的字符串

第 15 行的 wrapMap 用于处理 IE 9 以下版本浏览器的兼容问题,详细见文件 jQuery/src/manipulation/wrapMap.js,此函数不在本次讨论范围内

没有匹配到指定的标签,代码转入 17-30 行,这段代码先过滤 HTML 代码(/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([a-z][^\/\0>\x20\t\r\n\f]*)[^>]*)\/>/gi),之后清空每个元素的内容和绑定的事件,最后用 innerHTML 添加内容。若都没有捕获异常的话,把 elem 变量赋值为 0,以跳过第 36-38 行的代码块

 

好,前面啰嗦这么多,现在知道了当参数中包含 <script><style><link> 后,会在当前元素上先执行 empty(),再使用 append() 进行后续操作。

查看 empty() 方法的实现,发现是直接移除当前元素的绑定事件,释放内存,再删掉元素中的全部 node

// jQuery/src/manipulation.js

empty: function() {
    var elem,
        i = 0;

    for ( ; ( elem = this[ i ] ) != null; i++ ) {
        if ( elem.nodeType === 1 ) {

            // Prevent memory leaks
            jQuery.cleanData( getAll( elem, false ) );

            // Remove any remaining nodes
            elem.textContent = "";
        }
    }

    return this;
},

 

接着我们把目光转向 append() 方法:

// jQuery/src/manipulation.js

append: function() {
    return domManip( this, arguments, function( elem ) {
        if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) {
            var target = manipulationTarget( this, elem );
            target.appendChild( elem );
        }
    } );
},
// jQuery/src/manipulation.js

function domManip( collection, args, callback, ignored ) {

    // Flatten any nested arrays
    args = concat.apply( [], args );

    var fragment, first, scripts, hasScripts, node, doc,
        i = 0,
        l = collection.length,
        iNoClone = l - 1,
        value = args[ 0 ],
        isFunction = jQuery.isFunction( value );

    // We can't cloneNode fragments that contain checked, in WebKit
    if ( isFunction ||
        ( l > 1 && typeof value === "string" &&
            !support.checkClone && rchecked.test( value ) ) ) {
        return collection.each( function( index ) {
            var self = collection.eq( index );
            if ( isFunction ) {
                args[ 0 ] = value.call( this, index, self.html() );
            }
            domManip( self, args, callback, ignored );
        } );
    }

    if ( l ) {
        fragment = buildFragment( args, collection[ 0 ].ownerDocument, false, collection, ignored );
        first = fragment.firstChild;

        if ( fragment.childNodes.length === 1 ) {
            fragment = first;
        }

        // Require either new content or an interest in ignored elements to invoke the callback
        if ( first || ignored ) {
            scripts = jQuery.map( getAll( fragment, "script" ), disableScript );
            hasScripts = scripts.length;

            // Use the original fragment for the last item
            // instead of the first because it can end up
            // being emptied incorrectly in certain situations (#8070).
            for ( ; i < l; i++ ) {
                node = fragment;

                if ( i !== iNoClone ) {
                    node = jQuery.clone( node, true, true );

                    // Keep references to cloned scripts for later restoration
                    if ( hasScripts ) {

                        // Support: Android <=4.0 only, PhantomJS 1 only
                        // push.apply(_, arraylike) throws on ancient WebKit
                        jQuery.merge( scripts, getAll( node, "script" ) );
                    }
                }

                callback.call( collection[ i ], node, i );
            }

            if ( hasScripts ) {
                doc = scripts[ scripts.length - 1 ].ownerDocument;

                // Reenable scripts
                jQuery.map( scripts, restoreScript );

                // Evaluate executable scripts on first document insertion
                for ( i = 0; i < hasScripts; i++ ) {
                    node = scripts[ i ];
                    if ( rscriptType.test( node.type || "" ) &&
                        !dataPriv.access( node, "globalEval" ) &&
                            jQuery.contains( doc, node ) ) {

                        if ( node.src ) {

                            // Optional AJAX dependency, but won't run scripts if not present
                            if ( jQuery._evalUrl ) {
                                jQuery._evalUrl( node.src );
                            }
                        } else {
                            DOMEval( node.textContent.replace( rcleanScript, "" ), doc );
                        }
                    }
                }
            }
        }
    }

    return collection;
}

 

append() 方法短得人畜无害,但是它调用了 domManip() 函数,这个函数贴在博客上就显得恶意满满了,因为它有点长。↑ 就是上面被折叠起来的东西,不要轻易点开(雾

其实呢,包括 append() 方法和 domManip() 函数我们都不需要逐行分析,因为我们看到了我们真正感兴趣的东西,嗯,贴在下面:

// jQuery/src/manipulation.js
if ( hasScripts ) {
    doc = scripts[ scripts.length - 1 ].ownerDocument;

    // Reenable scripts
    jQuery.map( scripts, restoreScript );

    // Evaluate executable scripts on first document insertion
    for ( i = 0; i < hasScripts; i++ ) {
        node = scripts[ i ];
        if ( rscriptType.test( node.type || "" ) &&
            !dataPriv.access( node, "globalEval" ) &&
                jQuery.contains( doc, node ) ) {

            if ( node.src ) {

                // Optional AJAX dependency, but won't run scripts if not present
                if ( jQuery._evalUrl ) {
                    jQuery._evalUrl( node.src );
                }
            } else {
                DOMEval( node.textContent.replace( rcleanScript, "" ), doc );
            }
        }
    }
}

 

第 10-12 行,进行简单的判断后,终于到了激动人心的执行脚本步骤了:

如果标签不包含 src 属性,那么就把去除标签的纯脚本传入 DOMEval() 方法执行,就像下面那样。

新建一个 script 元素,把纯脚本写入该元素,最后添加在 <head> 末尾再移除,用以执行这个脚本。

// jQuery/src/core/DOMEval.js

function DOMEval( code, doc ) {
    doc = doc || document;

    var script = doc.createElement( "script" );

    script.text = code;
    doc.head.appendChild( script ).parentNode.removeChild( script );
}

 

如果标签包含了 src 属性,那么先判断 jQuery._evalUrl 这个函数是否存在,若存在则调用它,说白了就是又调用了 jQuery.ajax

// jQuery/src/manipulation/_evalUrl.js

jQuery._evalUrl = function( url ) {
    return jQuery.ajax( {
        url: url,

        // Make this explicit, since user can override this through ajaxSetup (#11264)
        type: "GET",
        dataType: "script",
        cache: true,
        async: false,
        global: false,
        "throws": true
    } );
};

 

发现已经调用了 jQuery.ajax,那么这次对 jQuery 的 html() 方法的探索就到此结束,因为 jQuery.ajaxdataType 设置为 scriptjsonp 时是可以跨域请求 JavaScript 文件并执行的。

 

Zepto.js

接着我们来看看轻量级(suoshui)的 Zepto.js 是如何表现的。

妈的整篇代码看不到一个分号,特么你就是这样把大小减下来的么,搞得我格式化个代码累得半死,坑,也不知道你是怎么正常工作的…(╯°口°)╯(┴—┴

 

依旧找到 html() 的主入口:

// Zepto.js/src/zepto.js

html: function(html){
    return 0 in arguments ?
        this.each(function(idx){
            var originHtml = this.innerHTML
            $(this).empty().append( funcArg(this, html, idx, originHtml) )
        }) :
        (0 in this ? this[0].innerHTML : null)
}

 

相比于 jQuery 的 html() 方法,它这个真是缩水。

看第 4 行,它使用了 0 in arguments 来判断是否传入了参数。经过性能测试,这个方法在数组长度较大时,性能会比 arguments.length 更优,但是毕竟这只是判断数组是否为空,并没有统计出具体长度,性能更好也是应该的。

若没有传入参数,则跳入第 9 行,直接通过 innerHTML 获得其中内容,与 jQuery 一致。否则,也是先调用 empty() 再执行 append() 进行后续操作。

第 7 行的 funcArg() 函数不用在意,因为 Zepto.js 允许 html() 方法的参数为一个函数,其功能只是当参数为函数时调用它,不为函数时直接返回当前字符串:

// Zepto.js/src/zepto.js

function funcArg(context, arg, idx, payload) {
    return isFunction(arg) ? arg.call(context, idx, payload) : arg
}

 

append() 的实现使用了一个多功能函数,把 “after”、”prepend”、”before”、”append” 四个功能整合在了一起:

// Zepto.js/src/zepto.js

// Generate the `after`, `prepend`, `before`, `append`,
// `insertAfter`, `insertBefore`, `appendTo`, and `prependTo` methods.
adjacencyOperators.forEach(function(operator, operatorIndex) {
    var inside = operatorIndex % 2 //=> prepend, append

    $.fn[operator] = function(){
        // arguments can be nodes, arrays of nodes, Zepto objects and HTML strings
        var argType, nodes = $.map(arguments, function(arg) {
            var arr = []
            argType = type(arg)
            if (argType == "array") {
                arg.forEach(function(el) {
                    if (el.nodeType !== undefined) return arr.push(el)
                    else if ($.zepto.isZ(el)) return arr = arr.concat(el.get())
                    arr = arr.concat(zepto.fragment(el))
                })
                return arr
            }
            return argType == "object" || arg == null ?
                arg : zepto.fragment(arg)
        }),
        parent, copyByClone = this.length > 1
        if (nodes.length < 1) return this

        return this.each(function(_, target){
            parent = inside ? target : target.parentNode

            // convert all methods to a "before" operation
            target = operatorIndex == 0 ? target.nextSibling :
                operatorIndex == 1 ? target.firstChild :
                    operatorIndex == 2 ? target :
                        null

            var parentInDocument = $.contains(document.documentElement, parent)

            nodes.forEach(function(node){
                if (copyByClone) node = node.cloneNode(true)
                else if (!parent) return $(node).remove()

                parent.insertBefore(node, target)
                if (parentInDocument) traverseNode(node, function(el){
                    if (el.nodeName != null && el.nodeName.toUpperCase() === 'SCRIPT' &&
                        (!el.type || el.type === 'text/javascript') && !el.src){
                        var target = el.ownerDocument ? el.ownerDocument.defaultView : window
                        target['eval'].call(target, el.innerHTML)
                    }
                })
            })
        })
    }

    // after    => insertAfter
    // prepend  => prependTo
    // before   => insertBefore
    // append   => appendTo
    $.fn[inside ? operator+'To' : 'insert'+(operatorIndex ? 'Before' : 'After')] = function(html){
        $(html)[operator](this)
        return this
    }
})

 

就是上面这辣鸡代码,看不到一个分号…我也是服了,真是佩服得五体投地…

嗯,这代码也没啥好看的,也就是最后的 44-48 行内容,是重点!!

如果是 <script> 标签,并且没有 src 属性!!!喵喵喵喵喵?所以有 src 属性就直接不做任何响应是么…

于是,内联脚本就直接调用 eval() 函数执行了…

 

这终于解开了 Zepto.js 只能执行内联脚本而无法加载外部脚本的谜题。

神坑!


原创文章,转载请以链接形式注明出处:https://blog.ttionya.com/article-1591.html