分类 默认分类 下的文章

闭包

其实在《有用但不愿意使用的机制 — JavaScript 作用域》一文中,不经意地使用了闭包特性。反过来,闭包最大的特点是可以实现模块功能。

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。
<button>A</button>
<button>B</button>
<button>C</button>
<button>D</button>
<p id="output"></p>
var buttons = document.querySelectorAll('button');
var output  = document.querySelector('#output');

for (var i = 0; i < buttons.length; ++i) {
    buttons[i].addEventListener('click', function (e) {
        output.innerText = buttons[i].innerText;
    }, false)
}

看似会正常运行的代码,居然在点击后出乎意料地报错了

Uncaught TypeError: Cannot read property 'innerText' of undefined
  • Click 事件触发时 i 变量已是 4
  • 循环时 i 变量存在于全局作用域
  • 运行时的监听器读取循环结束后的变量 i (4)

顺利运行

创建回调函数

<button>A</button>
<button>B</button>
<button>C</button>
<button>D</button>
<p id="output"></p>
var buttons = document.querySelectorAll('button');
var output  = document.querySelector('#output');


buttons.forEach(function(ele) {
  ele.addEventListener('click', function (e) {
      output.innerText = e.target.innerText;
  }, false)
})

JavaScript 的数组被赋予 forEach 等方法,它们需要传入回调函数来接受每个循环元素,作为循环体以执行。

这样很好地解决了 for 循环时的作用域问题。

使用 let 关键字

对于原来的代码,只需要改动一个地方即可,将对计数器 i 的定义从 var 改成 let,便可以在循环体内形成块级作用域,让每一次循环的执行都能保留当前计数器的数值和引用。—— 《实战 ES2015》
for (let i = 0; i < buttons.length; ++i) {
    buttons[i].addEventListener('click', function (e) {
        output.innerText = buttons[i].innerText;
    }, false)
}

使用 IIFE

使用立即执行函数包裹,解决闭包问题。

for (var i = 0; i < buttons.length; i++) {
    (function (index) {
        buttons[i].addEventListener('click', function (e) {
            output.innerText = buttons[index].innerText;
        }, false)
    })(i);
}

曲线救国

既然 i 变量有问题,为何不使用回调函数中的参数 e ,这样就可以读取按钮中文本了。

for (var i = 0; i < buttons.length; i++) {
    buttons[i].addEventListener('click', function (e) {
        output.innerText = e.target.innerText;
    }, false)
}

对例子的反思

以上的代码,尝试用点击事件来说明作用域问题,使人处于一个可运行,又可能会出问题的迷茫中。

使用另外一个例子来说明

for(var i = 1; i <= 5; i++) {
   setTimeout(function() {
       console.log('Value of i : ' + i); 
   },100);
} 
Value of i : 6
Value of i : 6
Value of i : 6
Value of i : 6
Value of i : 6

问题便显而易见,使用上面的 let 关键字 或 IIFE 即可解决这个问题。

作用域 Scope

词法作用域是一套关于引擎如何查找变量以及存储变量的规则

JavaScript 代码片段在执行前都要进行编译,与大多数语言不同,编译的过程不是发生在构建前(在浏览器中也没有构建),而是在执行前。

作用域使我们可以有效访问变量或函数的区域。大致可分为三种情况,词法作用域函数作用域块作用域

两年前我在 《混乱作用域、闭包问题》一文中描述了几个关于作用域的例子,说明了混乱的原因,但没有深入分析理解 JavaScript 作用域。

变量的定义和赋值

要理解作用域的产生,首先要理解变量是怎么被定、赋值和引用的。

var a = "a"

编译器会在当前作用域中声明变量 a,如果之前没被声明过。运行时引擎会在作用域中查找该变量,如果能找到就对它赋值。

词法作用域

词法作用域就是定义在词法编译阶段的作用域。该作用域是由你编写代码时变量和块语句来决定的。

//global namespace
var g = "global";

function globalFunc() {
  var a = "a"
  function innerFunc() {
    var i = "i"
    var g = "innerFunc"
    console.log(a) // a
    console.log(i) // i
    console.log(g) // innerFunc
    console.log(window.g) // global
  }
  innerFunc();
}

globalFunc() // "innerFunc"

var b = "b"
if(b) {
    var b = "new b"
    console.log(b) // new b
}

在函数中声明变量,是常见的作用域嵌套现象,不仅发生在函数中,还发生在块{}中。

那么引擎就会尝试在当前块和函数中查找这个变量,又或者在外层嵌套中继续查找,直到知道为止。

函数作用域

我们常在模块设计和对象设计时,运用到函数作用域,设计时遵循最小暴露原则,避免变量冲突情况。

//global namespace
var g = "global";

function globalFunc() {
  var a = "a"
  function innerFunc() {
    var i = "i"
    var g = "innerFunc"
    console.log(g) // innerFunc
  }
  innerFunc();
}

globalFunc()   // innerFunc
console.log(a) // a is not defined
console.log(i) // i is not defined
console.log(g) // global

函数作用域是指,属于这个函数的全部变量都可以在整个函数内使用。

但函数作用域也会产生一些意外的问题,比如占用了 globalFunc 声明,还有必须显式调用 globalFunc() 才能运行代码。IIFE 是非常好的解决在某些情况下如果不需要函数名和要求自动运行的问题。

IIFE 立即调用函数表达式

IIFE 一般由括号运算符和匿名/具名函数组成。

(function globalFunc() {
    var a = 2
})();

globalFunc() // globalFunc is not defined

被包含在括号内 () 的函数具有独立的词法作用域,不仅避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域。尾部的括号 (),表示引擎会执行该语句。

块作用域

除了函数内存在作用域,还存在其他类型的作用域,如 try/catch let const 等。

我在 《混乱作用域、闭包问题》一文中提到的例子:

<button>A</button>
<button>B</button>
<button>C</button>
<button>D</button>
<p id="output"></p>
var buttons = document.querySelectorAll('button');
var output  = document.querySelector('#output');

for (var i = 0; i < buttons.length; ++i) {
    buttons[i].addEventListener('click', function (e) {
        output.innerText = buttons[i - 1].innerText;
    }, false)
}

该程序理想结果是点击按钮,按钮中的字符会显示在页面上。实际上并非如此,任意按钮都只是输出 D

实际上 i 变量创建在全局, i 变量作为参数绑定到了每个监听器在响应函数中,那么结果都是一样。

for (let i = 0; i < buttons.length; ++i) {
    buttons[i].addEventListener('click', function (e) {
        output.innerText = buttons[i].innerText;
    }, false)
}

let 关键字可以让 i 变量绑定到 { } 括号内,这样每次点击按钮的回调就可以获取到对应的按钮对象。

image

Chrome 开发者工具调试代码

使用开发者工具可以很直观的让我们理解到这个问题,任意界面按 F12 ,将这段代码粘贴到文档中打开。

image

for 循环中的代码块中并没有创建作用域,导致 i 变量泄露到外围作用域,addEventListener 的匿名函数调用的是全局的 i 变量,当 for 循环执行完毕。观察右侧的 Watch 一栏,变量 i 的值是 4

为观察点击事件的回调函数执行情况,在行 5 行 6 加入断点。

image

分析 var i = 0; 这条语句,在全局作用域,通过 var i 声明了变量 i

buttons[i - 1].innerText; 执行时引用了全局作用域上的 var i

点击任意 A B C D 按钮,结果如猜想一致。变量 i 的值已经是 4,点击任意的按钮结果都是 4

var let const 关键字的区别

通过块作用域的例子,可以很容易地得总结出 let const 关键字形成的作用域

varletconst
作用域函数作用域块级作用域块级作用域
作用域内声明提升
是否可重复声明
是否可重复赋值
初始化时是否必需赋值

参考

Document Object Model (DOM) 文档对象模型

DOM 是 Web 页面(HTML 文档)的编程接口,它描述了文档的结构,代表了页面的众多对象组成。并且提供了方式可以读取、修改页面结构、样式和内容。

DOM 将 Web 页面组织成一个节点和对象,它是 Web 开发的关键工具之一。使得程序语言(一般指 JavaScript)可以经过 DOM 和 Web 页面连接起来。

我们把一个Web 页面称作 HTML 文档,这个文档在计算机中以 HTML 源代码存储。 但浏览器在解析 HTML 源代码时,以 DOM 的形式表现,并提供给开发者操控 DOM 的功能。

DOM 提供了一系列对象都有属性和方法,当你修改或调用它们时,浏览器会响应这些改动并更新到 Web 页面上。

DOM 含有什么内容?

一般的浏览器实现 DOM 都包含下列的对象,在开发 Web 应用程序时,通常使用的是 Document 和 window 对象。

  • document 对象,常用的有 document.getElementById(id)
  • element 对象,常用的有 element.innerHTML
  • window 对象,常用的有 window.location
  • screen 对象,常用的有 screen.width
  • CSS 样式属性
  • 事件模型 DEM(DOM Event Model)
使用 Javascript 和 DOM 在页面中创建内容
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <script>
    window.onload = function () {
      heading      = document.createElement("h1");
      heading_text = document.createTextNode("Big Head!");
      heading.appendChild(heading_text);
      document.body.appendChild(heading);
    }
  </script>
</body>

</html>

DOM Event Model (DEM) 文档事件模型

有了事件,开发者就可以处理用户与浏览器内容之间的交互,比如敲击键盘、点击鼠标,从而改变文档内容。

监听事件

一般情况下都可以为页面上的元素添加事件监听器,完成自定义的动作:

myButton.addEventListener('click', greet, false);

function greet(event) {
    console.log('greet: ' + arguments);
    alert('Hello world');
}

EventTarget.addEventListener() 方法将指定的函数绑定到 EventTarget 对象上,该对象触发了指定事件时,绑定的函数就会被执行。

EventTarget 可以是页面上的元素,文档,和视窗( Element、Document、Window)。

事件流

如 W3C 规范的事件流所示,一个事件的生命周期包括:捕捉目标冒泡三个阶段。

捕捉(红色)阶段,事件从文档树的顶端传播到目标元素。浏览器会沿着当前文档的树结构,找出事件涉及的元素,它就是事件的目标。

textblock.addEventListener('mouseout', handleDescendantMouseEvent, true)

addEventListener 第三个参数为 true 时,元素的事件将会在捕捉阶段发生。

使用 event.stopPropagation() 方法可以在捕捉阶段阻止事件传播到目标元素。

目标(蓝色)阶段,完成捕捉阶段后,浏览器会触发目标元素上任何已添加的事件。

冒泡(绿色)阶段,完成目标阶段后,浏览器开始沿着上级元素链向文档顶部前进,途径的没有启用捕捉的事件,都会被触发。

textblock.addEventListener('mouseout', handleDescendantMouseEvent, false)

addEventListener 第三个参数为 false 时,元素的事件将会在捕捉冒泡发生。

撤销默认行为

浏览器提供的可交互的组件,比如输入框、按钮、超链接,都具备默认行为,对应的事件就会被触发。

event.preventDefault()

在 Event.defaultPrevented 属性可得 preventDefault() 是否被调用过。

参考

数据类型

ECMAScript 标准定义了 8 种数据类型:

7 种原始类型 和 Object

  • Boolean
  • Null
  • Undefined
  • Number
  • BigInt
  • String
  • Symbol

使用 typeof 操作符判断对象类型

console.log(typeof 42);
// expected output: "number"
类型结果
Undefined"undefined"
Null"object"
Boolean"boolean"
Number"number"
BigInt(ECMAScript 2020 新增)"bigint"
String"string"
Symbol (ECMAScript 2015 新增)"symbol"
Function 对象 (按照 ECMA-262 规范实现 [[Call]])"function"
其他任何对象"object"

typeof 只能检测基本数据类型,包括 Boolean、Undefined、String、Number、Symbol,而 Null、Array、Object 检测出来都是 Object,无法检测具体是哪种引用类型。

如何判断数据类型是数组?

比如要检测一个变量/数据类型是否为数组 Array,使用 Array.isArray()

Array.isArray()

在编程环境运行的情况下,isArray 是最简单直接的检测是否为数组的有效方法

// 下面的函数调用都返回 true
Array.isArray([]);
Array.isArray([1]);
Array.isArray(new Array());
Array.isArray(new Array('a', 'b', 'c', 'd'))
// 鲜为人知的事实:其实 Array.prototype 也是一个数组。
Array.isArray(Array.prototype);

原型链检测对象类型

instanceof

instanceof 运算符用来检测对象的构造函数 constructor.prototype 是否出现在某个实例对象的原型链上。

[] instanceof Array
//true

new Object() instanceof Object
//true

'a' instanceof String; //false
//false

不建议使用 instanceof 检测 String 类型数据,String 对象和 Date 对象都属于 Object 类型,他们是由 Object 类派生出来的。

Object.prototype.toString.call()

对于检测数据,被检测对象必定会存在 toString 方法,如果 toString 方法没有重写的话,会返回 [object type],其中 type 为对象的类型。

// only implement if no native implementation is available
if (typeof Array.isArray === 'undefined') {
  Array.isArray = function(obj) {
    return Object.prototype.toString.call(obj) === '[object Array]';
  }
}

undefined 和 null 的区别

一个已定义但没有被赋值的变量,或函数如果没有使用 return 语句指定返回值,就会返回一个undefined值。

null 是 JavaScript 7 种原始类型之一。null 是表示缺少的标识,指示变量未指向任何对象。

typeof null        // "object" (因为一些以前的原因而不是'null')
typeof undefined   // "undefined"
null === undefined // false
null  == undefined // true 因为undefined派生自null
null === null // true
null == null // true
!null //true
isNaN(1 + null) // false
isNaN(1 + undefined) // true

参考

JavaScript 数据类型和数据结构

Array.isArray() - MDN

typeof - MDN

instanceof - MDN

Object.prototype.toString()

undefined

null

How to check if an object is an array?

Determining with absolute accuracy whether or not a JavaScript object is an array

this 为何而生

随着你的使用模式越来越复杂,显示传递上下文对象会让代码变得越来越乱,使用 this 这不会。

this 提供了一种更优雅得方式来隐式“传递”一个对象引用,因此可以将 API 设计得更加简洁并且易于复用。

this 在任何情况下都不指向函数的词法作用域,this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用

把人绕晕的题目

开发者往往会把理解 this 的过程复杂化,并把它作为进阶中高级开发者的门槛。即使是非常有经验的 JavaScript 开发者也很难说清楚它到底指向什么。

var fullName = 'a';
var obj = {
    fullName: 'b',
    prop:{
        fullName: 'c',
        getFullName: function(){
            console.log(this);
            return this.fullName
        }
    }
}

console.log(obj.prop.getFullName());

var test = obj.prop.getFullName;
console.log(test());
console.log(obj.prop.getFullName())

严格来说 getFullName() 函数不属于 obj 对象,但调用位置会使用 obj 上下文来引用函数。

//console.log(this);
{fullName: "c", getFullName: ƒ}

c

getFullName() 函数被调用时,obj 对象包含了它,隐式绑定规则会把函数调用的 this 绑定到 obj 上下文,因此 thisobj.prop 是一样的;

console.log(test())
a

test 变量通过别名的方式引用了 getFullName() 函数,此时 test() 是一个不带任何修饰的函数调用,因此应用了默认绑定 ,this 指向全局对象


var num = 1;
var myObject = {
    num: 2,
    add: function() {
        this.num = 3;
        (function() {
            console.log(this.num);
            this.num = 4;
        })();
        console.log(this.num);
    },
    sub: function() {
        console.log(this.num)
    }
}


myObject.add();

console.log(myObject.num);

console.log(num);

var sub = myObject.sub;
sub();
myObject.add()

add 函数中含有匿名函数,在匿名函数中 this 被重新绑定到全局对象,所以匿名函数中的 this 是 window ;最后修改 window.num 为 4

1

add 函数执行时 隐式绑定myObject 上下文,一般情况下此时的 this 就是 myObject

this.num = 3; 率先将 myObject.num 赋值为 3

3
console.log(myObject.num)

add 函数执行后修改了 myObject.num 为 3

3
console.log(num)

这里的 num 指的是 window.numadd 函数中的匿名函数执行后修改了 window.num 为 4

4
sub()

sub 变量是 myObject.sub() 函数的引用,此时 sub() 是一个不带任何修饰的函数调用,因此应用了默认绑定 ,this 指向全局对象,即是 window.num

4

绑定规则

默认绑定
隐式绑定
显式绑定
new 绑定

参考