AJAX里的状态锁与封装

本文系原创,转载前请邮件联络作者:scorpionlc@yeah.net或直接在评论中留言等候回复

学习AJAX的时候,对状态锁、代码封装两个部分很感兴趣。状态锁保证了在一些特殊情况下发出正确请求,获得正确的返回数据;代码封装使得代码可读性提升,代码结构化且适合维护。两者都非常有用,因此我写一个博客来梳理一下。


>>> 为什么需要状态锁?

当数据请求速度/网速很慢的时候,如果用户多次点击请求按钮,那么很有可能发出多次重复的请求,在get方式下,如果不对用户的多次重复点击做出处理,那么每次构造的URL很有可能是一致的,最终就会返回很多重复的数据,违背了开发者的初衷。

状态锁是一种优雅的方法,概括而言:状态锁事先声明一个变量,其中true表示开启(锁住用户操作,用户操作无效),false表示关闭(用户可以进行操作,操作将被处理),其核心的步骤如下:

1. 初始状态下,状态锁是关闭的,用户可以进行操作

1
var lock = false;

2. 创建AJAX对象时进行逻辑判断,如果状态锁为开启(true)状态,那么将忽视用户的频繁点击,否则将发送请求

1
2
3
if(lock){
return;
}

3. 请求一经发出,需要经历处理过程,在这时,状态锁启动,直到响应就绪才关闭,否则,状态锁开启,无法进行请求

1
2
3
4
5
6
7
8
9
lock = true;
xhr.onreadystatechange = function(){
if(xhr.readyState === 4){
...
lock = false;
}
}else{
lock = true;
}

>>> 不设置状态锁会怎样

以下是不设置状态锁时前端页面和服务器的代码,只需要观察Network的请求就可以获知问题:

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
<div id="ct">
<ul id="news"></ul>
<button id="btn">点我加载</button>
</div>
<script>
function $(id){
return document.querySelector(id);
}
var btn = $("#btn");
var ul = $("#news");
var pageIndex = 0;
btn.addEventListener("click", function(){
var xhr = new XMLHttpRequest();
xhr.open("get", "/loadMore?index=" + pageIndex + "&length=5", true);
xhr.send();
xhr.onreadystatechange = function(){
if(xhr.readyState === 4){
if(xhr.status === 200 || xhr.status === 304){
var results = JSON.parse(xhr.responseText);
console.log(results);
var fragment = document.createDocumentFragment();
for(var i = 0; i < results.length; i++){
var node = document.createElement("li");
node.innerText = results[i];
fragment.appendChild(node);
}
ul.appendChild(fragment);
pageIndex = pageIndex + 5;
}else{
console.log("error");
}
}
};
})
</script>
1
2
3
4
5
6
7
8
9
10
11
12
app.get('/loadMore', function(req, res) {
var pageIndex = parseInt(req.query.index);
var length = parseInt(req.query.length);
data = [];
for(var i = 0; i < length; i++){
var news = "新闻" + (i + pageIndex).toString();
data.push(news);
}
setTimeout(function(){
res.send(data)}, 5000
)
});

服务端故意让每次的响应时间延迟5s,也就是点击后不会立即有数据渲染在页面上,数据拖延了5s才向前端进行发送。如果用户很急迫地一连点击5次按钮,返回结果是:

其原因是:每次的readyState都没有到4(请求已完成,响应已经就绪)时,用户就已经迫不及待地发出了下一个请求,这时候的pageIndex并没有执行加5的操作,导致每次的请求都是http://localhost:8080/loadMore?index=0&length=5,而当数据全部展现到页面上后,再进行一次点击,此时的pageIndex已经变成25了,新的请求就会变成http://localhost:8080/loadMore?index=25&length=5,输出结果会非常的混乱。


>>> 添加状态锁

按照之前的说法,加入一个状态锁可以保证的效果是:当响应还没有完成的时候,无论用户怎么点击按钮,我都让这一行为return为空,也即不返回任何结果/不产生任何效力。

以下是添加注释的JS代码。

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
// 状态锁初始状态为关闭(false)状态,用户可以发出请求
var lock = false;
function $(id){
return document.querySelector(id);
}
var btn = $("#btn");
var ul = $("#news");
var pageIndex = 0;
btn.addEventListener("click", function(){
var xhr = new XMLHttpRequest();
// 如果状态锁状态为开启(true),则忽略用户点击操作,不发送AJAX请求
if(lock){
return;
}
// 如果状态锁状态为关闭,则发送AJAX请求
if(!lock) {
xhr.open("get", "/loadMore?index=" + pageIndex + "&length=5", true);
xhr.send();
// 执行过程中,状态锁为开启状态,用户无论怎样点击都是无效的
lock = true;
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200 || xhr.status === 304) {
var results = JSON.parse(xhr.responseText);
console.log(results);
var fragment = document.createDocumentFragment();
for (var i = 0; i < results.length; i++) {
var node = document.createElement("li");
node.innerText = results[i];
fragment.appendChild(node);
}
// 如果响应就绪,状态锁为关闭状态,用户可以进行下一次的请求
lock = false;
ul.appendChild(fragment);
pageIndex = pageIndex + 5;
} else {
console.log("error");
// 否则,响应出错,状态锁保持开启状态
lock = true;
}
}
};
}
})

添加状态锁后,返回的结果会变为正常。


>>> AJAX封装

AJAX封装的第一个出发点:一个页面上通常有多处需要使用AJAX,如果不进行封装,每次需要使用AJAX时,都需要写相似度极高的代码,造成信息冗余,而AJAX封装抽取出普遍的通则,这样在多次使用AJAX时仅需要直接调用封装完成的代码即可,便利了前端开发。

AJAX封装的第二个出发点:将复杂的问题进行拆解,由大化小,且力求使得每一个降解的子代码段变得逻辑更加简洁。如果不进行合理的封装,代码中的一个函数内既有if...else...,又有循环,还有其他的变量计算,看起来非常缺乏条理。

针对这一弊端,将原有的代码按照功能划分成多个子部分,比如专门负责创建AJAX的、专门处理数据请求的、数据到来之后渲染页面的,这样在AJAX最核心的部分中只需要调用定义的函数,整个代码段的结构会非常明晰,也方便后期维护。

1. 基础的变量声明放在script的最前面

1
2
3
4
var btn = document.querySelector("#load-more");
var ct = document.querySelector("#ct");
var pageIndex = 0;
var isDataArrive = true;

2. 创建事件侦听器的时候,尽量在核心部分使用函数,函数的布局跟着逻辑行进,比如:1)数据尚未来临如何应对 2)数据来临如何应对 3)加载数据 4)渲染页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
btn.addEventListener("click", function(e) {
e.preventDefault();
// 数据尚未来临,操作无效
if (!isDataArrive) {
return;
}
// 否则执行数据加载,加载的数据为news,因为news需要经过处理才能展现在页面上,因此构建一个匿名函数用以渲染页面
loadData(function (news) {
renderPage(news);
pageIndex = pageIndex + 5;
isDataArrive = true;
})
isDataArrive = false;
});

3. 在实际应用场景中,一个页面中会有很多需要利用AJAX的地方,所以经常是传递一个AJAX对象,然后直接将其中的value放到相应的函数中。其中每一个AJAX对象应该包含这些要素:1)请求方式 2)请求接口地址 3)传递的参数 4)请求成功后执行什么 5)请求失败后执行什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function loadData(callback){
// 请求方式、URL、参数、请求成功后怎样、请求失败后怎样
// ajax("get", url, data, onSuccess, onError)
ajax({
type: "get",
url: "/loadMore",
data: {
index: pageIndex,
length: 5
},
// 请求成功后执行,这里的callback相当于上一段代码后中的匿名函数
onSuccess: callback,
onError: function(){
console.log("error")
}
})
}

4. 既然已经定义好了AJAX对象,就要开始将其中的value放入对应的函数中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function ajax(options){
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function(){
if(xhr.readyState === 4){
if(xhr.status === 200 || xhr.status === 304){
var results = JSON.parse(xhr.responseText)
// 往`callback`中传递参数
options.onSuccess(results);
}else{
options.onError();
}
}
}
var query = "?";
for(key in options.data){
query += key + "=" + options.data[key] + "&"
}
query = query.substr(0, query.length - 1);
xhr.open(options.type, options.url + query, true);
xhr.send();
}

5. 接下来是渲染页面的部分

1
2
3
4
5
6
7
8
9
function renderPage(news){
var fragment = document.createDocumentFragment();
for(var i = 0; i < news.length; i++){
var node = document.createElement("li");
node.innerText = news[i];
fragment.appendChild(node);
}
ct.appendChild(fragment);
}

>>> 总结

AJAX封装最明显的特征就是:大问题拆解为小问题,但是小问题之间又环环相扣。需要熟悉的是AJAX对象,以及如何将对象中的值与回调函数结合起来。当然在面临更加灵活的AJAX对象时(比如需要综合考虑到getpost两种请求方式,数据返回格式可能不是JSON字符串),需要对代码做出更优化封装,以应对更多样的情况并考虑到容错。

原生JS实现跨域

本文系原创,转载前请邮件联络作者:scorpionlc@yeah.net或直接在评论中留言等候回复


这是一篇对于跨域的总结,将涵盖跨域的四种方法:

  • jsonp
  • cors
  • 降域
  • postMessage

在回顾每种方法时都会结合自己的实践。


>>> 什么是跨域?

在介绍跨域之前首先要了解何为“同源策略”(Same Origin Policy),浏览器(注意:主体是浏览器)出于安全方面的考虑,只允许与本域(同协议、同域名、同端口)下的数据接口进行交互,不同源的客户端脚本在没有授权的情况下,是不能读写对方资源的。

可以设想一下:如果没有同源策略,如果我自己建了一个网站,然后在没有支付宝客户端脚本授权的情况下轻松操控支付宝的脚本,随意传入我的个人信息,或者获得其他用户支付宝的数据,那将是非常危险的。同源策略有效地阻止了诸如此类的危险行为。

但是请设想这样一种场景:我自己建设了一个网站,这时候需要在网站上建设一个天气控件,背后的数据我必须从一些天气网站或者数据接口中进行获取,但是由于同源策略的限制,我无法实现这一目标。因此跨域就应运而生了。JS在不同域之间进行数据传输或者通信,譬如AJAX向一个不同源的服务端去请求数据,或者利用JS获取页面中不同域的iframe数据,从而实现不同域数据的相互访问,这些情境归根结底都是跨域。


>>> 跨域方法1:jsonp

jsonp全称:json with padding,这个名称非常地形象。意思就是异步请求跨域服务端时,不直接返回数据,而是返回一个JS方法,数据是其中的参数。其实就相当于数据变成了馅料,填充(padding)在一个方法里面,然后返回并运行。

为什么会用这么巧妙的一种方法呢?实际上,在书写HTML时如果需要引用JQuery,只需要在页面中加上<script src = "http://code.jquery.com/xxx"></script>就可以了,之后在HTML中就能调用JQuery中已经封装好的各种方法,但是code.jquery.com与请求页面的域名肯定不一样,jsonp正是借鉴了这一点来实现跨域的数据访问。

我的电脑是Windows系统,首先我在我的host文件中添加以下新域名:

1
2
3
4
5
# New Hosts
127.0.0.1 a.com
127.0.0.1 b.com
127.0.0.1 a.lyndon.com
127.0.0.1 b.lyndon.com

为何要在host文件中添加这些?因为在浏览器地址栏中输入域名后,需要根据域名去寻找对应的IP地址,这就是所谓的DNS解析,首先是在浏览器的缓存中寻找,如果没有找到,就去系统的host文件中寻找,再没有找到,就去路由器缓存中找,再往深处就是ISP DNS,根域名服务器。

我在本地启动server-mock,最原始的客户端页面和服务端页面代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<div class="container">
<p class="show">0000</p>
<button class="btn">change</button>
</div>
<script>
function $(id){
return document.querySelector(id);
}
$(".btn").addEventListener("click", function(){
var xhr = new XMLHttpRequest();
xhr.open("get", "/change", true);
xhr.send();
xhr.onreadystatechange = function(){
if(xhr.readyState === 4 && xhr.status === 200){
console.log(JSON.parse(xhr.responseText));
append(JSON.parse(xhr.responseText));
}
}
});
function append(data){
$(".show").innerText = data.data[0];
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.get('/change', function(req, res){
array = [
"1111",
"2222",
"3333",
"4444",
"5555"
];
var data = [];
data.push(array[parseInt(Math.random() * array.length)]);
res.send({
data: data
});
});

在这种情境下,是能够进行正常请求的,因为请求页面请求的是同域服务端的数据。

但是当我稍对客户端页面的代码做更改,就会出现不一样的结果。

1
xhr.open("get", "http://b.lyndon.com:8080/change", true);

因为http://a.com:8080http://b.lyndon.com:8080不同域,浏览器限制了我的跨域请求。

这时候使用jsonp的思路来做一些调整,这时候我就不再使用AJAX方法,而是加入一个script标签,点击“change”按钮时,scriptsrc属性将直接从服务端返回一个方法(回调函数),数据将作为其中的参数。客户端页面和服务端页面代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function $(id){
return document.querySelector(id);
}
// jsonp
$(".btn").addEventListener("click", function(){
var script = document.createElement("script");
script.src = "http://b.lyndon.com:8080/change?callback=process";
document.head.appendChild(script);
// 及时删除,防止加载过多的JS
document.head.removeChild(script);
});
function process(data){
$(".show").innerText = data[0];
}
1
2
3
4
5
6
7
8
9
10
11
12
app.get('/change', function(req, res){
array = [
"1111",
"2222",
"3333",
"4444",
"5555"
];
var data = [];
data.push(array[parseInt(Math.random() * array.length)]);
res.send(req.query.callback + "(" + JSON.stringify(data) + ")");
});

因为在客户端加入了回调函数,因此在服务端稍作更改即可,返回的是一个function_name(data),这样一来,即使脱离了server-mock,也可以愉快地执行了。

  • 客户端域名为:a.com:8080

  • 单独执行html


>>> 跨域方法2:CORS

使用CORS方法和AJAX原代码几近类似,主要工作是在服务端加上响应头res.header(“Access-Control-Allow-Origin”, “xxx”),只要响应头中包含了请求头(Origin),就可以实现跨域,相当于数据请求的决定权在于服务端是否同意,因此CORS对于代码的修改也只需修改服务端代码即可。

客户端和服务端的代码如下:

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
<div class="ct">
<ul class="nums">
<li>111</li>
<li>222</li>
<li>333</li>
</ul>
<button class="btn">换一组</button>
</div>
<script>
function $(id){
return document.querySelector(id);
}
$(".btn").addEventListener("click", function(){
var xhr = new XMLHttpRequest();
xhr.open("get", "http://b.com:8080/getNums", true);
xhr.send();
xhr.onreadystatechange = function(){
if(xhr.readyState === 4 && xhr.status === 200){
appendHtml(JSON.parse(xhr.responseText));
}
}
})
function appendHtml(nums){
var html = "";
for(var i = 0; i < nums.length; i++){
html += "<li>" + nums[i] + "</li>";
}
console.log(html);
$(".nums").innerHTML = html;
}
</script>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
app.get('/getNums', function(req, res) {
var array = [
"444",
"555",
"666",
"777",
"888",
"999",
"000"
]
var data = [];
for(var i = 0; i < 3; i++){
data.push(array[parseInt(Math.random() * array.length)]);
array.splice(parseInt(Math.random() * array.length), 1);
}
res.header("Access-Control-Allow-Origin", "http://b.com:8080");
res.send(data);
});

在以上的服务端代码中,设定的允许域为http://b.com:8080,在进行访问时,如果打开localhost:8080,虽然存在数据交换但是无法更新页面。

将访问页的域名改为http://b.com:8080即可正常访问。

如果为了方便,希望来自所有域的请求都可以自由获取服务端的数据,那么只需要改为:res.header("Access-Control-Allow-Origin", "*");即可。


>>> 跨域方法3:降域

降域使得处于不同域的两个HTML文件实现相互访问或相互操作成为可能。一个非常典型的使用场景:在一个页面中存在一个iframe,但是iframe中的网页与包含网页不同域,使用降域的方法可以实现两个页面内容的同步更改,因为只有处于同域条件才能使用JS操作其中的元素。

需要注意的一点是:降域的使用是存在限制的,域名中需要有一致的父级域名才可以使用降域

比如:a.lyndon.comb.lyndon.com,它们拥有一致的父级域名:lyndon.com,因此可以进行降域从而实现跨域,而a.comb.com无法进行降域,同理,类似于a.jrg.comb.lik.com也不行。

降域的实现很简单,以刚才提及的使用场景为例:只需要在两个html文件的script中加入共同的代码document.domain="lyndon.com";即可。

以下展现a.html和b.html的代码:

1
2
3
4
5
6
7
8
9
10
11
<div class="main">
<input type="text" placeholder="http://a.lyndon.com:8080/a.html">
</div>
<iframe src="http://b.lyndon.com:8080/b.html" frameborder="0"></iframe>
<script>
document.querySelector(".main input").addEventListener("input", function(){
console.log(this.value);
window.frames[0].document.querySelector("#input").value = this.value;
});
document.domain = "lyndon.com";
</script>
1
2
3
4
5
6
7
8
<input type="text" id="input" placeholder="http://b.lyndon.com:8080/b.html">
<script>
document.querySelector("#input").addEventListener("input", function(){
console.log(this.value);
window.parent.document.querySelector("input").value = this.value;
});
document.domain = "lyndon.com";
</script>

这里的window.frames返回的是一个类数组对象,成员为页面内所有的框架,包括frame元素和iframe元素,window.frames内的每个成员是框架内的窗口(框架的window对象),如果需要获取每个框架的DOM树,就需要像以上代码一样写成window.frames[0].document的形式。

在第二段(b.html)的代码中,iframe内部使用的window.parent指向的是父页面。因此第二段代码中的window.parent.document.querySelector("input")对应的是第一段代码中的input,这样的做法在两个代码文件中建立起了相互的连接。

实际效果如下:


>>> 跨域方法4:postMessage(window对象才有postMessage方法

介绍postMessage之前,需要明确一点:iframe元素遵守同源政策,只有当父页面与框架页面来自同一个域名,两者之间才可以用脚本通信,否则只有使用window.postMessage方法

因此可以明确得知:postMessage的使用范围是更加广阔的,且当降域不可行时(如:a.com和b.com无法降域)时,使用postMessage会是一个不错的选择

这里依然以页面与嵌套的iframe消息传递这一场景为例。postMessage(data, origin)方法接受两个参数:

  • data:要传递的数据,为了让所有浏览器都能正常解析,建议使用:JSON.stringify()方法将对象参数序列化
  • origin:目标窗口的源,postMessage()方法会将message传递给指定窗口,同CORS中一样,如果将origin设置为*,就可以将message传递给任意窗口

与postMessage(发送消息)对应的是接收消息,因此与postMessage相互搭配的是监听window的message事件。

以下给出两份添加注释的html代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<div class="ct">
<input type="text" placeholder="http://a.lyndon.com:8080/a.html">
</div>
<iframe src="http://localhost:8080/b.html" frameborder="0"></iframe>
<script>
// 将输入的信息传递给页面上的不同域的iframe(b.html)
document.querySelector(".ct input").addEventListener("input", function(){
console.log(this.value);
window.frames[0].postMessage(this.value, "http://localhost:8080/b.html");
});
// 监听b.html(本页面上的iframe)是否有message传递过来,如果有,将输入框中的内容换成iframe中input里的输入内容
window.addEventListener("message", function(e){
document.querySelector(".ct input").value = e.data;
console.log(e.data);
});
</script>
1
2
3
4
5
6
7
8
9
10
11
12
<input type="text" id="input" placeholder="http://b.lyndon.com:8080/b.html">
<script>
// 当有输入的文字时,向父页面(a.html)发出message
document.querySelector("#input").addEventListener("input", function () {
window.parent.postMessage(this.value, "http://a.lyndon.com:8080/a.html");
});
// 监听a.html是否有message传递过来,如果有,将iframe输入框中的内容换成a.html中input里的输入内容
window.addEventListener("message", function(e){
document.querySelector("#input").value = e.data;
console.log(e.data);
});
</script>

所以归根结底,postMessage就是一个信息交叉的过程。实际执行效果是:


>>> 附加一个自己的实践:使用jsonp获取百度联想词

  • 首先在Console中Network查看百度搜索词的联想词获取地址

联想词的数据地址为:https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd=%E6%BC%82%E4%BA%AE%E7%9A%84&json=1&p=3&sid=1452_21099_18559_21673&req=2&csor=3&pwd=%20&cb=jQuery110208414170774720962_1486043984005&_=1486043984013
精简URL,可以发现:"https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd=" + string即可返回联想词。后面需要加上callback"cb=" + function name来进行返回结果的处理。

  • 动态获取跨域数据
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
function $(id){
if(document.querySelectorAll(id).length > 1){
return document.querySelectorAll(id);
}else{
return document.querySelector(id);
}
}
var txt = $("#txt"),
ul = $("#baidusug"),
script = null;
txt.onkeyup = function (){
ul.innerHTML = "";
if (script) {
document.body.removeChild(script);
}
script = document.createElement("script");
script.src = "https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd=" + txt.value + "&cb=process";
document.body.appendChild(script);
};
function process(json){
for(var i = 0; i < json["s"].length; i++){
var li = document.createElement("li");
li.innerHTML = json.s[i];
ul.appendChild(li);
}
}
  • 最后的结果


>>> 总结

在今后的使用过程中,只需要辨清场景,然后按照因地制宜的原则选择一种跨域方法就好,没有必要完全依赖于一种特定的方法。一言以蔽之:没有最正确的,只有最适合的。

理解闭包

本文系原创,转载前请邮件联络作者:scorpionlc@yeah.net或直接在评论中留言等候回复

闭包对于我而言是一个难点,但闭包又是一个很有用的知识点,很多高级应用都需要依赖闭包。
所以在参考一些文章加上大量练习后,我来写一写自己理解闭包的过程,首先是弄清楚以下几个知识点。


>>> Part 1. 变量的作用域

JS中,变量的作用域只有两种:全局作用域、函数作用域。对应的变量也只有两种:全局变量、局部变量。

函数内部可以直接读取全局变量。

1
2
3
4
5
var a = 1;
function f(){
console.log(a);
}
f(); // 1

但是函数外部无法读取到函数内部的局部变量。

1
2
3
4
function f(){
var a = 1;
}
console.log(a); // Uncaught ReferenceError: a is not defined

这一个Part是比较好理解的。


>>> Part 2. 如何从外部读取到局部变量?

在禄永老师的公开课中,老师将从外部读取局部变量这一情况称作“伟大的逃脱”。总结而言,有两种方法来实现。

  • 返回值的方法:函数作为返回值
1
2
3
4
5
6
7
8
9
function f1(){
var a = 1;
function f2(){
console.log(a);
}
return f2;
}
var result = f1();
result(); // 1

函数f2包裹在函数f1内,根据作用域链的原理:子对象会一级一级向上寻找父对象的变量,f1所有的局部变量都可以被f2访问到,反之则不行。因此只要把f2作为返回值,就可以在f1外部读取到其中的内部变量。

  • 句柄的方法:定义全局变量
1
2
3
4
5
6
7
8
9
10
11
var innerHandler = null;
function outerFunc(){
var outerVar = 1;
function innerFunc(){
console.log(outerVar);
var innerVar = 2;
}
innerHandler = innerFunc;
}
outerFunc();
innerHandler(); // 1

这一方法首先定义了一个值为null的全局变量innerHandler,然后让innerHandler等于函数内部的函数,函数内部的函数则可以通过作用域链访问到父对象的变量outerVar,之后在外部调用innerHandler的时候,就可以访问到outerFunc函数中的内部变量outerVar


>>> Part 3. 闭包

网络上有千万种对闭包的解释,其实闭包就是上面例子中的两个函数:f2以及innerFunc。书面解释就是:能够读取其他函数内部变量的函数

在JS中,因为父函数内部的子函数才能够读取局部变量,因此闭包的常见形式就是:定义在函数内部的函数。前者是后者的充分不必要条件。

一言以蔽之,闭包就是连接函数内部外部的渠道。


>>> Part 4. 对示例代码段的解答

  • 第一段代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function outerFn() {
console.log("Outer function");
function innerFn() {
var innerVar = 0;
innerVar++;
console.log("Inner function\t");
console.log("innerVar = "+innerVar+"");
}
return innerFn;
}
var fnRef = outerFn();
fnRef();
fnRef();
var fnRef2 = outerFn();
fnRef2();
fnRef2();

在这一段代码当中,innerFn不是一个闭包,因为它并不需要读取其他函数的内部变量,唯一的变量innerVar就在innerFn函数内部。在第一个fnRef()之后,结果就是首先输出Outer function,然后输出Inner function,由于innerVar是函数innerFn的内部变量且自增,因此从0变为1,再输出innerVar = 1.

这时候需要明白,当再次运行fnRef()时,由于fnRef本身已经变成了函数innerFn,所以其输出结果就不再有Outer Function这一句,而是直接输出:Inner function以及innerVar = 1.原因是此时的innerVar是一个内部变量,其作用域限定在innerFn函数中,每次调用执行innerFn函数,innerVar都会被重写。

对于下面的fnRef2(),也是同理。最后的输出结果见下图:

  • 第二段代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var globalVar = 0;
function outerFn() {
console.log("Outer function");
function innerFn() {
globalVar++;
console.log("Inner function\t");
console.log("globalVar = " + globalVar + "");
}
return innerFn;
}
var fnRef = outerFn();
fnRef();
fnRef();
var fnRef2 = outerFn();
fnRef2();
fnRef2();

这里的globalVar是一个外部变量,也是一个全局变量,处于全局作用域下。所以当执行innerFn时,innerFn函数将会访问到一个每次都自增的全局作用域下的活动对象,因此输出的结果会从globalVar = 1一直到globalVar = 4.在执行间歇中,globalVar处于两个函数的作用域之外,天高地远谁也管不了,所以它的值会被保存在内存中,并不会立刻被抹去。最后的输出结果见下图:

  • 第三段代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function outerFn() {
var outerVar = 0;
console.log("Outer function");
function innerFn() {
outerVar++;
console.log("Inner function\t");
console.log("outerVar = " + outerVar + "");
}
return innerFn;
}
var fnRef = outerFn();
fnRef();
fnRef();
var fnRef2 = outerFn();
fnRef2();
fnRef2();

闭包来临了,这里的fnRef是一个闭包innerFn函数,但是此时的变量outerVar来到了父函数的作用域内,不像之前一样处于子函数作用域内或者处于全局作用域下。可以发现,这和Part 2中的例子非常相似。

其原理是:外部函数的调用环境为相互独立的封闭闭包的环境,第二次的fnRef2调用outerFn没有沿用第一次调用fnRefouterVar的值,第二次函数调用的作用域创建并绑定了一个新的outerVar实例,两个闭包环境中的计数器是相互独立,不存在关联的。

进一步来说,在每个封闭闭包环境中,外部函数的局部变量会保存在内存中,并不会在外部函数调用后被自动清除。原因在于:outerFninnerFn的父函数,而innerFn被赋值给一个全局变量,因此innerFn始终在内存当中,而它又依赖于outerFn,所以outerFn也必须始终在内存中,不会再函数被调用后就被抹去,因此闭包也有一点点不好,有可能造成内存泄漏。

所以,结果应该是:outerVar = 1, outerVar = 2, outerVar = 1, outerVar = 2.结果如下图所示:

我写到这自己已经完全明白了,我现在要用自己的理解来理顺一下最经典的问题。


>>> Part 5. 理顺最经典问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="divTest">
<span>0</span>
<span>1</span>
<span>2</span>
<span>3</span>
</div>
<script>
var spans = document.querySelectorAll("#divTest span");
for(var i = 0; i < spans.length; i++){
spans[i].onclick = function(){
console.log(i);
}
}
</script>

最经典的问题是:为什么我点击任何数字,控制台的输出结果永远是4?

这里可使用作用域链来帮助理解,不妨将以上代码转化为:

1
2
3
4
5
// function只是传递给了NodeList类型对象中的元素却并未执行,因为后面无括号
spans[0] = function fn0(){console.log(i)};
spans[1] = function fn1(){console.log(i)};
spans[2] = function fn2(){console.log(i)};
spans[3] = function fn3(){console.log(i)};
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
globalContext = {
AO: {
i: undefined, // 0(fn0)1(fn1)2(fn2)3(fn3)4(终止循环)
spans:[0], [1], [2], [3]
},
scope: null
}
fn0[[scope]] = globalContext.AO,
fn1[[scope]] = globalContext.AO,
fn2[[scope]] = globalContext.AO,
fn3[[scope]] = globalContext.AO
fn0Context = {
AO:{
},
scope: fn0[[scope]]
}
fn1Context = {
AO:{
},
scope: fn1[[scope]]
}
fn2Context = {
AO:{
},
scope: fn2[[scope]]
}
fn3Context = {
AO:{
},
scope: fn3[[scope]]
}

最后点击span元素的时候i早已变为4,因此永远输出4.

改进的方法可以使用闭包,也就是:

1
2
3
4
5
6
7
8
var spans = document.querySelectorAll("#divTest span");
for(var i = 0; i < spans.length; i++) {
spans[i].onclick = function(i){
return function (){
console.log(i);
}
}(i);
}

这个闭包也可以用作用域链来理解:

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
globalContext = {
AO:{
i: undefined,
spans: [0], [1], [2], [3]
}
}
fn0.scope = globalContext.AO,
fn1.scope = globalContext.AO,
fn2.scope = globalContext.AO,
fn3.scope = globalContext.AO
fn0Context = {
AO:{
i: 0,
function: anonymous
}
fn0[[scope]] = fn0.scope // globalContext.AO
}
function_anonymousContext = {
AO: {
}
function_anonymous[[scope]] = fn0Context.AO
}
...

>>> Part 6. 闭包的问题

如同刚才的分析一样,当涉及到闭包时,函数中的变量都会被保存在内存中,因此需要避免滥用闭包,否则就有可能导致内存泄露。


>>> 参考资料

JS事件:一道题的多种解法

本文系原创,转载前请邮件联络作者:scorpionlc@yeah.net或直接在评论中留言等候回复

>>> 引言

目前JS部分的学习进度到了事件,我发现很多题目已经可以融合之前学过的各种JS基础知识了。今天将从一道题目入手,来探究一道题的多种解法(不是全部解法,毕竟JS还没有学完),顺带回顾之前所学习的内容。

题目 补全代码,要求:当鼠标放置在li元素上,会在img-preview里展示当前li元素的data-img对应的图片。
源代码如下:

1
2
3
4
5
6
7
8
9
<ul class="ct">
<li data-img="1.jpg">鼠标放置查看图片1</li>
<li data-img="2.jpg">鼠标放置查看图片2</li>
<li data-img="3.jpg">鼠标放置查看图片3</li>
</ul>
<div class="img-preview"></div>
<script>
//你的代码
</script>

我这里自定义的三张图片如下:


>>> 解法1:正则表达式

我的初步想法是:因为要在div class="img-preview"中展现图片,所以最重要的实现手段就是在div标签中加上一个img标签。由于这里的图片和代码文件处于同一个文件路径之下,我可以先取出每个li中图片的名称,然后利用字符串拼接将图片包裹在img中加入到div里。
这个解法首先要取出图片名称,因此我使用正则表达式。总结起来,完整的思路是:

  • 使用querySelectorAll()方法选择所有的li元素,用querySelector()方法选择将要用于图片展示的div元素
  • 因为querySelectorAll()方法返回匹配指定的CSS选择器的所有节点,返回的是NodeList类型的对象,所以用forEach()方法遍历返回的结果,在每一个li元素上使用addEventListener()方法指定事件处理程序
  • innerHTML属性在div中加入img标签,将图片名称附着进去
1
2
3
4
5
6
7
8
9
10
11
var pics = documents.querySelectorAll("li"),
preview = document.querySelector(".img-preview");
pics.forEach(function(li){
li.addEventListener("mouseenter", function(){
// 使用RegExp取出图片名称
var pic = this.outerHTML.toString().match(/[0-9]\.jpg/)[0];
// 为了方便观察,加一个控制台输出结果
console.log(pic);
preview.innerHTML = "<img src =" + pic + ">";
})
});

最后的结果完全符合题目要求,只要鼠标悬停在相应的li元素上面,就会在下方出现当前li元素的data-img对应的图片。

在正则表达式匹配过程中需要注意:如果单独用this.outerHTML返回的是一个Object,没有match方法,所以需要加上toString()方法进行转换。

用这个解法完成后,我进行了反思,虽然这个解法代码简单,但是存在一个很大的缺陷:题目中图片的名称相对来说是比较类似的,但是如果一旦没有共性,正则表达式的匹配很可能就失去效力,如果希望用很多个正则表达式去匹配图片名称,代码就会显得很复杂。


>>> 解法2:创建元素并添加属性,将事件绑定到父元素上

总体思路是:

  • 使用createElement()方法来生成HTML元素节点
  • 使用getAttribute()方法来获取相应lidata-img值,再使用setAttribute()方法来给刚才创建的元素节点设置元素属性

这样做的好处是:不再受到图片名称的局限,虽然代码会稍稍复杂一些。

1
2
3
4
5
6
7
8
9
10
11
12
var ct = document.querySelector(".ct"),
preview = document.querySelector(".img-preview"),
// 创建元素节点
image = document.createElement("img");
ct.addEventListener("mouseover", function(e){
src = e.target.getAttribute("data-img");
// 为了方便观察,加一个控制台输出结果
console.log(src);
// 设置元素属性
image.setAttribute("src", src);
preview.appendChild(image);
});

需要注意的是:这里需要使用的事件类型不是上一种方法用到的mouseenter,而是mouseover,两者的区别在于:不论鼠标指针穿过被选元素或其子元素,都会触发mouseover事件;只有在鼠标指针穿过被选元素时,才会触发mouseenter事件,mouseover对应mouseoutmouseenter对应mouseleave
如果在现在的解法中使用mouseenter,就会出现报错,因为这里添加事件处理程序的元素是ul class="ct",因此mouseenter在这种场景下会返回null,因为ul本身是没有"data-img"属性的。而li作为ul的子元素,只要鼠标指针穿过li,就能触发mouseover事件,从而顺利获取到对象中的"data-img"属性值。


>>> 解法3:综合解法1和解法2,兼用循环和属性获取

这个方法就是比较安全、不容易出错的方法。解法1中使用的是forEach()方法,这里直接使用常见的for循环

1
2
3
4
5
6
7
8
var pics = document.querySelectorAll(".ct>li"),
preview = document.querySelector(".img-preview");
for(var i = 0; i < pics.length; i++){
pics[i].addEventListener("mouseenter", function(){
var img = this.getAttribute("data-img");
preview.innerHTML = "<img src=" + img + ">";
});
}

>>> 总结

通过思考一道题的多种解法,回顾了之前已经学习的部分JS基础知识,关键要点如下:

  • 明确mouseentermouseover的使用场景
  • 摆脱循环可能会让代码更加简单
  • 尽量直接使用DOM中Element对象已有的增、删、改、查方法,可以有效提升效率

BTW,我始终相信最好的解法永远是学习更多知识后写出的下一个解法。

HTMLCollection vs. NodeList

本文系原创,转载前请邮件联络作者:scorpionlc@yeah.net或直接在评论中留言等候回复

>>> 引文

这篇博客文章缘起于我对一道题目的思考,原题是:

elem.childrenelem.childNodes的区别?

那么这两者的区别究竟是什么呢?当时我写了这样一段代码(这段代码只得到表面上的答案,是浅层次理解)。

1
2
3
4
5
6
7
8
9
<div id="ct">
<p class="para">Lyndon</p>
<p class="attr">123<span>dozz</span></p>
</div>
<script>
var ct = document.getElementById("ct");
console.log(ct.children);
console.log(ct.childNodes);
</script>

返回的结果是:

可以看出,当我用getElementById方法匹配到id = "ct"的元素节点后

  • ct.children返回的是一个HTMLCollection(图中已用红框标出),其中包含的两个元素是p.para以及p.attr
  • ct.childNodes返回的是一个NodeList(图中已用红框标出),其中包含的元素稍微多些,有5项:text, p.para, text, p.attr, text

每一个元素不断展开,会发现有很多的属性,零零碎碎的,这时候我发现一个比较明显的区别是textContent的不同:

  • HTMLCollection
    • p.paratextContent"Lyndon"
    • p.attrtextContent"123dozz"
  • NodeList
    • texttextContent"↵ "
    • p.paratextContent"Lyndon"
    • texttextContent"↵ "
    • p.attrtextContent"123dozz"
    • texttextContent"↵ "

究竟为何两个方法会返回不一样的结果?有课件归纳如下:

两者的不同点在于:

  1. HTMLCollection对象具有namedItem()方法,可以传递id或name获得元素;
  2. HTMLCollection的item()方法和通过属性获取元素(document.forms.f1)可以支持id和name,而NodeList对象只支持id

但是我并没有完全看懂,大概掌握程度是0.6左右,于是我觉得解决这些疑惑的终极方法,应该是去深入了解:HTMLCollectionNodeList本质上的不同


>>> 首先,参考stack overflow上的回答

我翻译了一下Vote数最高的答案:

HTMLCollectionNodeList都是DOM节点的集合,两者都属于Collections范畴,两者的区别在于:

  • 方法略有差异HTMLCollectionNodeList多了一个namedItem方法,其他方法保持一致
  • 包含节点类型不同NodeList可以包含任何节点类型,HTMLCollection只包含元素节点(ElementNode)

什么时候会用到Collections?

  • 当返回多个节点(如:getElementByTagName)或者得到所有子元素(如:element.childNodes)时,Collections就会出现,这时候就有可能返回HTMLCollection或者NodeList

>>> 其次参考W3的文档(MDN上也有详细解释):

HTMLCollection是以节点为元素的列表,可以凭借索引、节点名称、节点属性来对独立的节点进行访问。HTML DOM中的Collections是实时变动的,当原始文件变化,Collections也会随之发生变化。

  • 属性:length(返回的是列表的长度)
  • 方法1:item(通过序号索引来获取节点,参数是索引值,超过索引值返回null)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <div id="ct">
    <p class="para">Lyndon</p>
    <p class="attr">123<span>dozz</span></p>
    <form action="" method="get" name="apply">
    <input type="text" name="username" placeholder="用户名">
    <input type="password" name="password" placeholder="密码">
    </form>
    </div>
    <script>
    var ct = document.getElementById("ct");
    var a = ct.children;
    var b = ct.childNodes;
    console.log(a);
    console.log(b);
    </script>

  • 方法2:namedItem(用名字来返回一个节点,首先搜寻是否有匹配的id属性,如果没有就寻找是否有匹配的name属性,如果都没有,返回null)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <div id="ct">
    <p class="para">Lyndon</p>
    <p class="attr">123<span>dozz</span></p>
    <form action="" method="get" name="apply">
    <input type="text" name="username" placeholder="用户名">
    <input type="password" name="password" placeholder="密码">
    </form>
    </div>
    <script>
    var ct = document.getElementById("ct");
    var a = ct.children;
    var b = ct.childNodes;
    console.log(a);
    console.log(b);
    </script>

NodeList返回节点的有序集合,DOM中的NodeList也是实时变动的

  • 属性:length(列表中节点的数量)
  • 方法:item(返回集合中的元素,如果超过范围返回null)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <div id="ct">
    <p class="para">Lyndon</p>
    <p class="attr">123<span>dozz</span></p>
    <form action="" method="get" name="apply">
    <input type="text" name="username" placeholder="用户名">
    <input type="password" name="password" placeholder="密码">
    </form>
    </div>
    <script>
    var ct = document.getElementById("ct");
    var a = ct.children;
    var b = ct.childNodes;
    console.log(a);
    console.log(b);
    </script>


>>> Element与Node

到这一步,两者本质上的区别已经差不多分清楚了,那么现在就要进入第二个问题,为什么两个Element属性返回的结果(如:textContent)不一样呢?这里需要感谢 joyside,他推荐给我一篇文章《Element和Node的区别你造吗?》来理解Element和Node的区别。
文章中参考的是MDN:Node是一个基础类型,document, element, text, comment, DocumentFragment等都继承于Node. 在这篇文章最开始的测试中NodeList结果中有非常多的text,其实element, text, comment都是Node的子类,可以将它们视为:elementNode, textNode以及commentNode.平时在DOM中最常用的Element对象,其本质就是elementNode.
由于Node就是DOM的结构,代码内容经过解析后,Node与Node之间可以插入文本,文章最开头的截图中的"↵ "本质上就是Node之间的空隙,这种空隙的本质是textNode.


>>> 总结

综上所述,进行归纳,并回答文章开头提出的疑问。

  • HTMLCollectionNodeList的共同点显而易见:

    1. 都是类数组对象,都有length属性
    2. 都有共同的方法:item,可以通过item(index)或者item(id)来访问返回结果中的元素
    3. 都是实时变动的(live),document上的更改会反映到相关对象上(例外:document.querySelectorAll返回的NodeList不是实时的)
  • HTMLCollectionNodeList的区别是:

    1. NodeList可以包含任何节点类型,HTMLCollection只包含元素节点(elementNode),elementNode就是HTML中的标签
    2. HTMLCollectionNodeList多一项方法:namedItem,可以通过传递id或name属性来获取节点信息
  • 文章开头的疑问解答:
    文章开头的代码实际上等价于:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    这里是介于node与node之间的textNode
    <div id="ct">
    这里是介于node与node之间的textNode
    <p class="para">Lyndon</p>
    这里是介于node与node之间的textNode
    <p class="attr">123<span>dozz</span></p>
    这里是介于node与node之间的textNode
    </div>
    <script>
    var ct = document.getElementById("ct");
    console.log(ct.children);
    console.log(ct.childNodes);
    </script>

由于NodeList包含任何节点类型,ct.childNodes会一并返回textNode, elementNode等,所以最终结果就是由text, p, text, p, text组成的类数组对象,这里的text只是换行符而已。
由于HTMLCollection仅包含elementNode,因此最终的结果就是由p.para, p.attr组成的类数组对象。当然,由于这里只返回直接的子元素,因此不会出现类数组对象中没有span,如果希望返回结果中有span,这样写就可以了:

1
2
3
4
5
6
7
8
9
10
11
<div id="ct">
<p class="para">Lyndon</p>
<p class="attr">
<span>dozz</span>
</p>
<span>bilibili</span>
</div>
<script>
var ct = document.getElementById("ct");
console.log(ct.children);
console.log(ct.childNodes);

有一些参考资料还详细给出了有哪些具体的方法可以获取HTMLCollectionNodeList对象,如果要记住可能比较麻烦,每次在具体情况时参考控制台的输出,得知类型后只需要记住常用的方法区别就可以轻松地进行操作了。


>>> 参考资料

  1. Difference between HTMLCollection, NodeLists, and arrays of objects
  2. Interface NodeList
  3. NodeList and HTMLCollection
  4. Interface HTMLCollection
  5. Element和Node的区别你造吗?