接着上一篇说到的jQuery工厂函数内部机制, 本篇将会阅读jq真正的构造函数, 也就是jQuery.fn.init部分.

一、更新


[2019-4-21]

Changed

  • 文章格式优化

二、前置知识点


2.1 XSS

关于XSS的描述, 网上也有很多, 就不在这里总结. 但是有一点还是要着重记录以下, 那就是:

  • PS: 由location.hash引起的XSS攻击问题

为什么要说这个? jQuery源码中有这么一段代码和注释:

1
2
3
4
5
// A simple way to check for HTML strings
// Prioritize #id over <tag> to avoid XSS via location.hash (#9521)
// Strict HTML recognition (#11290: must start with <)
// Shortcut simple #id case for speed
rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;

注意第二句, 假设$.append()方法接收一个用户输入值, 该用户正好使用location.href作为输入, 而location.hash存在恶意代码, 那么就会造成XSS. 而rquickExpr则是作为严格匹配:

  • 诸如<p>xxx</p>的html片段
  • 形如#xxx-xxx之类的IP选择器

结论很简单, 在CentBrowser中测试一下即可:

测试rquickExpr正则

2.2 exec和match

源码中用到了exec()方法, 由于它和match方法比较相似, 好吧, 总是搞混这两个, 这里就说一下它们的最主要的区别:

  • exec只返回第一个匹配的元素
  • match返回匹配样本的数量, 与g有关

三、jQuery构造函数


3.1 思路规划

有了上述的前置知识加持, 再来阅读源码.

首先, copy一下源码中的构造函数相关内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
init = jQuery.fn.init = function (selector, context, root) {
var match, elem;

// HANDLE: $(""), $(null), $(undefined), $(false)
if (!selector) {
return this;
}

// Method init() accepts an alternate rootjQuery
// so migrate can support jQuery.sub (gh-2101)
root = root || rootjQuery;

// Handle HTML strings
if (typeof selector === "string") {
if (selector[0] === "<" &&
selector[selector.length - 1] === ">" &&
selector.length >= 3) {

// Assume that strings that start and end with <> are HTML and skip the regex check
match = [null, selector, null];

} else {
match = rquickExpr.exec(selector);
}

// Match html or make sure no context is specified for #id
if (match && (match[1] || !context)) {

// HANDLE: $(html) -> $(array)
if (match[1]) {
context = context instanceof jQuery ? context[0] : context;

// Option to run scripts is true for back-compat
// Intentionally let the error be thrown if parseHTML is not present
jQuery.merge(this, jQuery.parseHTML(
match[1],
context && context.nodeType ? context.ownerDocument || context : document,
true
));

// HANDLE: $(html, props)
if (rsingleTag.test(match[1]) && jQuery.isPlainObject(context)) {
for (match in context) {

// Properties of context are called as methods if possible
if (jQuery.isFunction(this[match])) {
this[match](context[match]);

// ...and otherwise set as attributes
} else {
this.attr(match, context[match]);
}
}
}

return this;

// HANDLE: $(#id)
} else {
elem = document.getElementById(match[2]);

if (elem) {

// Inject the element directly into the jQuery object
this[0] = elem;
this.length = 1;
}
return this;
}

// HANDLE: $(expr, $(...))
} else if (!context || context.jquery) {
return (context || root).find(selector);

// HANDLE: $(expr, context)
// (which is just equivalent to: $(context).find(expr)
} else {
return this.constructor(context).find(selector);
}

// HANDLE: $(DOMElement)
} else if (selector.nodeType) {
this[0] = selector;
this.length = 1;
return this;

// HANDLE: $(function)
// Shortcut for document ready
} else if (jQuery.isFunction(selector)) {
return root.ready !== undefined ?
root.ready(selector) :

// Execute immediately if ready is not present
selector(jQuery);
}

return jQuery.makeArray(selector, this);
};

可以看到, 虽然简简单单的百十来行代码, 其内部逻辑还是挺复杂的. 但是并不慌, 我们先从参数分析:

  • selector
    • 具体的选择器, 可以是DOMCSS选择器jQuery对象等, 作为重点, 后续会具体分析
  • context
    • DOM元素或者jQuery对象
    • 好像很少用? 查了下资料, 它的作用是: 限制(减少)搜索的范围. 假设要获取一个.text元素, 此时按照context的提供与否, 会出现两种不同的内部机制:
      • (已提供): 会在context下查找.text元素
      • (未提供): 默认会在document下查找
    • 这对于性能来说是至关重要的——由于CSS解析从右至左进行, 浏览器看到.text会依次去判断其父级是不是context, 所以添加context可减少判断层级.
  • root
    • jQueyr根节点, 默认为document

经过对参数的简单分析, 接着来到构造函数内部, 大致浏览一下, 会发现大部分是根据selector参数做不同的逻辑处理, 这也是jQuery的核心部分.

好吧, 一眼看上去的确很乱, 但是其内部逻辑的目的是差不多的:

  • 根据selector的不同, 作不同的处理

所以, 我将其分为以下几个区块, 来逐个分析:

  • 无效参数值
  • 字符串
  • DOM节点
  • 函数
  • 其他
    • 类数组
    • 对象
    • 数组

3.2 逐个击破

3.2.1 无效参数值

先来看对无效参数值的处理逻辑:

1
2
3
if (!selector) {
return this;
}

如果传入了一个无效的、没有意义(null, undefined, false, 0, ‘’)的值, 直接返回空的jQuery.fn.init实例, 不作任何操作.

3.2.2 字符串
  1. 处理步骤

    • 计算match
      • match是之前提到的rquickExpr正则匹配到的结果, 这里要注意, 该正则执行后的分组匹配结果分别作为数组的第二和第三项
        • match[1] -> 标签字串(如: <kbd>xxx</kbd>)
        • match[2] -> id选择器(如: #xxx)
    • 根据match的结果来决定后续操作:
      • 如果match的值存在且为(html字符串id选择器), 会出现以下几种情况:
        • match[1] -> $(array)
        • 如果只匹配到单个html字符串, 调用**jQuery.merge****jQuery.parseHTML**方法, 将该字符串转化为jq对象
          • match[1] && context -> $(array)
        • 如果同时匹配到html字符串, 且用户提供了context上下文, 会转化为jquery对象, 并作为fragment挂载到context
          • match[2] -> $(array)
        • 如果匹配到形如#xxx之类的选择器, 调用getElementById, 并转化为jquery对象
          • match[2] && context -> $(array) –>
            • 如果匹配到诸如#xxx, 并且用户提供了context, 则继续判断context是否为原生DOM节点或者jquery对象, 进一步执行选取操作
      • 反之则表明是一个正常的CSS选择器, 继续判断context是否存在
        • 存在则通过调用this.constructor(context), 将其转化为jquery对象, 使用原型上的find方法来执行查询
        • 不存在则根据rootJquery来找寻
  2. jQuery.parseHTML&jQuery.merge


分割线 - [2019-4-10]更新


上述简单的列出了一部分逻辑, 并没有陈述过于详细, 毕竟作者的思路太严谨了.

但是你以为就这么简单?(好吧, 其实是我自己认为.), 将目光转向jQuery.mergejQuery.parseHTML这两个方法:

PS: 偷了几天懒, 翘课回家好好睡了几天觉, 周一继续更新…

这是两个挂载到jQuery上的静态方法, 何所谓静态方法, 它和实例方法的区别是什么? 对于此问题, 我觉得简书上的一篇文章写的通俗易懂:

简书——实例方法和静态方法有什么不一样?

简单来说:

  • 静态方法(static-method挂载于类本身), 常用于
    • 工具函数的定义
    • 复用性强的函数
  • 实例方法(ins-method)则反之
  1. jQuery.parseHTML

分割线 - [2019-4-11]更新


吃个饭回来继续更新…

PS: 源码太长, 不全部Ctrl+CV了, 大概在9771 - 9815

1
2
3
4
5
6
7
// Argument "data" should be string of html
// context (optional): If specified, the fragment will be created in this context,
// defaults to document
// keepScripts (optional): If true, will include scripts passed in the html string
jQuery.parseHTML = function(data, context, keepScripts) {
// 很多处理
}

简单看一下函数上面的注释,

PS: 源码上方的每一行注释都很重要, 通过它, 可以了解该方法的具体意图

其接收三个参数:

  • data
    • (必选)-必须为html字符串
  • context
    • (可选)-以Fragment的形式, 挂载解析后的结果
  • keepScripts
    • (可选)-是否过滤js

不难看出, parseHTML这个方法, 顾名思义, 它的作用就是:

  • 解析html字符串
  • 挂载到对应context

俗话说——擒贼先擒王, 代码也是如此, 知道了函数的作用, 那么其内部运作机理也就理所当然的显现了, 顶多就是做一些逻辑判断, 那么接下来就对其内部逻辑做个简单梳理.

对于parseHTML内部, 大致进行了以下处理流程:

  • 非法参数过滤

    • 对于无意义的参数直接返回空数组
  • 理解三个变量

    • base
      • 给所有解析出来的元素指定默认链接, 诸如aimg此类
    • parsed
      • 数组
      • 保存解析到的符合H5标准令牌规范标签字符串(尖括号字符串)
    • scripts
      • 数组
      • 根据参数keepScripts, 来决定是否解析script标签
  • 计算context

    • 如果没有提供context, 则会创建新的document来作为context

    • 看到源码中有这么一段注释

      1
      2
      3
      4
      // Support: Safari 8 only
      // In Safari 8 documents created via document.implementation.createHTMLDocument
      // collapse sibling forms: the second one becomes a child of the first one.
      // Because of that, this security measure has to be disabled in Safari 8.
      • Safari8中, 使用document.implementation.createHTMLDocument创建document会出现折叠孩子节点的情况, 也就是说, 我们创建了两个相同的兄弟节点, 第一个节点会被忽略, 这是开发中不能接受的
      • 所以jQuery源码中定义了support对象, 来作为检测兼容性的工具, 在这里使用其中的createHTMLDocument方法来做简单判断, 如果是非Safari8浏览器, 创建全新的document, 反之直接赋值给当前document.
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        if (support.createHTMLDocument) {
        context = document.implementation.createHTMLDocument("");

        // Set the base href for the created document
        // so any parsed elements with URLs
        // are based on the document's URL (gh-2965)
        base = context.createElement("base");
        base.href = document.location.href;
        context.head.appendChild(base);
        } else {
        context = document;
        }
  • 计算parsed

    • 这里用到了rSingleTag这个正则

      1
      var rsingleTag = (/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i);
    • 它是为了匹配一个相对纯净的标签($('<span />')$('<span></span>')), 何为相对纯净?

      • 子节点为空
      • 不具有任何属性
    • 如果rSingleTag捕获到标签

      • 直接在当前context作用域下创建新的HTMLElement
    • 反之, 表明有多层嵌套的标签, 通过调用buildFragment方法来统一处理

      • 那么问题来了, buildFragment又是什么鬼? 不急, VS Code按住Ctrl + MouseLeft, 去它内部看一看:
        • 我将其内部源码结构简化了一下, 大概是这样的:
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19
          20
          21
          22
          23
          24
          25
          26
          27
          28
          29
          30
          31
          32
          33
          34
          35
          36
          37
          38
          39
          40
          41
          function buildFragment(elems, context, scripts, ...args[]) {
          // 保存源html字符串
          // 也就是遍历elems得到的每一项
          var elem = '';
          // 源码中创建了div, 将其innerHTML的值设为elem
          // tmp保存该div的每个后代元素
          var tmp = '';
          // 保存解析elem得到的所有标签
          // 为了得到`script`标签
          var nodes = [];


          // 迭代操作, 取代传统递归方式
          // 遍历所有节点, 存储到`nodes`数组
          for(var i = 0; i < elems.length; i++) {
          ...
          var elem = elems[i];

          tmp = context.createElement('div');
          tmp.innerHTML = elem;

          while(j--) {
          tmp = tmp.lastChild;
          }

          tmp = fragment.firstChild;
          }


          // 计算`scripts`
          // 将取得的`script`标签存放入`scripts`数组
          while((elem = nodes[i++])) {
          tmp = getAll(fragment.appendChild(elem), 'scripts');

          if(scripts) {
          while((elem = tmp[j++])) {
          scripts.push(elem);
          }
          }
          }
          }
  • 计算scripts

    • 实际的计算操作位于buildFragment内部
  • 返回jQuery.merge()

    • merge方法会在下面说到

以上就是jQuery.parseHTML内部的复杂逻辑, 总结来说, 就是以下两方面:

  • 对于纯净的html标签字符串, 直接返回该标签数组
  • 对于多层嵌套的html标签字符串, 迭代其后代节点, 并保存script标签.
  1. jQuery.merge

分割线 - [2019-4-12]更新


之前的解析过程有些太啰嗦了, 对一个函数的内部剖析, 觉得没有必要, 看源码只是了解它的大致结构即可, 所以后面开始会对函数内部的处理逻辑加以简化, 对不常用的API不作解析, 避免浪费不必要的时间.

好了, 接着上一小节的jQuery.parseHTML的函数内部剖析, 了解到了:

  • 其内部通过对html令牌类型的标签字符串做处理, 返回jQuery.merge处理过的节点数组

这里问题就来了:

jQuery.merge是什么鬼?

好了, 定位到merge方法, 发现它是挂载于jQuery类上的方法, jQuery.parseHTMLbuildFragment等多处都调用了它, 这表明它是一个单纯的工具方法或者多复用函数

PS: 诸如类似的方法还有很多, 比如jQuery.ajax等等, 在后面的实例getpost方法都会使用到.

🆗, 看到其内部源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Support: Android <=4.0 only, PhantomJS 1 only
// push.apply(_, arraylike) throws on ancient WebKit
merge: function (first, second) {
var len = +second.length,
j = 0,
i = first.length;

for (; j < len; j++) {
first[i++] = second[j];
}

first.length = i;

return first;
},

函数内部的逻辑也很简单:

  • 将第二个数组的值传递到第一个数组

这里就不多说了, 那么, 可以总结出这么一个结论, 该函数的作用就是:

  • 合并第二个类数组的内容到第一项

第一项可以是:

  • 一个数组
  • jQuery对象
    • jQuery.merge内部会新增一个length属性,
3.2.3 DOM节点处理
1
2
3
4
5
6
...
else if(selector.nodeType) {
this[0] = selector;
this.length = 1;
return this;
}

对于传递的DOM节点选择器, 直接包装成jQuery对象并返回.

3.2.4 函数处理

在分析jQuery内部对于选择器为函数时的处理逻辑之前, 先来回顾一下它的用法:

1
2
3
4
$(document).ready(function() {
...
...
})

或者直接使用简写:

1
2
3
4
$(function() {
...
...
})

那么问题来了:

Q: 上述的两种方法有啥区别呢?

带着问题, 来看源码:

1
2
3
4
5
else if(jQuery.isFunction(selector)) {
return root.ready !=== undefined
? root.ready(selector)
: selector(jQuery);
}

可以看到, jQuery对传入的function调用了rootjQuery原型上的ready()方法, 该rootjQuery也就是$(document), 看到这里, 相信已经豁然开朗, 其实就是简化了代码量, 但是其本质都是一样的.

3.2.5 其它类型
1
2
3
...
...
return jQuery.makeArray(selector, this);

对于:

  • 不为0的数字
  • 数组
  • 类数组
    • HTMLCollection
    • NodeList

等等这些类型的数据, 会调用其静态方法——jQuery.makeArray来包装为jQuery对象.

既然用到了makeArray这个方法, 那么我们来简单分析一下它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// results is for internal usage only
makeArray: function (arr, results) {
var ret = results || [];

if (arr != null) {
if (isArrayLike(Object(arr))) {
jQuery.merge(ret,
typeof arr === "string" ?
[arr] : arr
);
} else {
push.call(ret, arr);
}
}

return ret;
},

它根据参数arr的类型来做了不同的处理:

  • arr为数组or类数组
    • 调用jQuery.merge合并二者
  • arr为其它
    • 直接合并

最终返回处理过的ret, 也就是result结果, 此时, arr的每一项, 已经被转化为results的各项, 当然, 由于该函数外部已经注明了:

注意: results is for internal usage only(该函数的结果仅供内部使用)

那么, 此时的results理所当然就是包装过的jQuery的实例(jQuery.fn.init)了.

四、总结


写到这里, jQuery构造函数的内部处理逻辑大概就分析完了, 由于太多太杂, 所以在最后, 画了一张脑图来串联一下这些知识点:

总结

五、示例代码


代码已用ts重构, 放置于Gayhub