上篇文章《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