上篇文章《html() 和 innerHTML 的小坑》我们分析了原生 JavaScript 的 element.innerHTML
和 jQuery 与 zepto.js 的 element.html()
两种方法在处理包含 <script>
标签的字符串时的异同。
本着知其然知其所以然的态度,我们这次从源码分析一下 element.html()
的工作原理,为什么 innerHTML
不能使其中的脚本执行,而 jQuery 的 html()
却可以,而 zepto.js 的只能执行内联脚本却不能加载外部脚本。
我们使用 jQuery 3.1.0 和 Zepto.js 1.2.0 的源码进行分析
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.ajax
在 dataType
设置为 script
和 jsonp
时是可以跨域请求 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