JS的作用域一共有三個範圍,分別是:
全局作用域:
定義在所有函數之外的變量,其作用範圍是在整個腳本中
局部作用域(函數作用域):
使用var定義在函數內部的變量,其作用範圍是整個函數結構,超出函數 {} 花括號的範圍則不能使用。
塊級作用域:
// ES6聲明變量的方式:let / const
// let 變量名 = 變量值;
// const 變量名 = 變量值;
// PS:注意: 使用const修飾的變量,賦值確定後,不允許再重新賦值。(一般修飾常量或者數組對象之類的)而且必須給予初始值。
// const修飾數組對象後雖然不可以再對變量進行等號賦值了,但是還是可以用數組和對象的方法去改變它的內部結構。
// IIFE(立即調用函數表達式 - - 自執行匿名函數):
// 英文全名:(Immediately Invoked Function Expression)
//語法
//這三種都屬於IIFE寫法
(function () {})();
(function () {}());
+function () {}();
//作用與ES6當中的let是一樣的,只不過用這個語法是爲了解決個別瀏覽器的兼容問題。
JS 質量的重點難點:
- 立即執行函數:又叫自執行函數,定義即執行
立即執行函數(
Immediately Invoked Function Expression
)即(1)定義一個匿名函數,(2)馬上調用該匿名函數。 它沒有綁定任何事件也無需等待任何異步才做,即可立即執行。用過JQuery的都知道,JQuery開篇用的就是立即執行函數。立即執行函數的好處在於能隔離作用域,並在私有作用域中執行邏輯,避免了變量污染和命名衝突。常見的兩種寫法如下
// 括號包圍函數體
(function(name) {
let greet= 'Hello';
let sayHi = () => console.log(`${greet} ${name}`)
sayHi()
// ...
})('zfs')
// 括號包圍全部內容
(function() {
let name = 'liz'
console.log(name)
// ...
} ())
經典面試題
for(var i = 0; i < 10; i++){
setTimeout(function() {
console.log(i); // 爲什麼輸出了十個10,而不是0-9
}, 0)
}
解釋:
爲什麼執行上述代碼輸出了十個10?其實想明白了就很容易了。
Javascript是單線程的,執行順序由上而下,而setTimeout
是典型的異步方法,其中的操作會被掛起,直到主隊列中的代碼執行完成後纔開始執行。
又因爲在for
循環中,變量i
是用具有變量提升效果的var
定義的,因此i
的作用域覆蓋全局。案例中,每次循環結束,都有一個console.log()
操作被掛起,當十次循環執行結束後,變量i
已經累計到10(注意不是9),此時退出循環主線程執行結束,開始執行掛起隊列中的打印邏輯,然而打印邏輯中的參數i
已經是10,因此每次打印出了十個10那這個問題怎麼處理?其實辦法有很多,如把
var
改成ES6中沒有變量提升效果塊作用域定義法的let
即可,每次循環執行時因i
爲塊作用域變量因此它的值都會被保留而不會被下次循環執行覆蓋。主要利用了保護執行時環境的思想。而這種思想使用立即執行函數也能實現// 注意要在被掛起之前保存執行環境否則就無效了,因此用IIFE包住異步函數 for (var i = 0; i < 10; i++) { (function(ii){ setTimeout(function(){ console.log(ii) }, 0) })(i) }
- 變量提升:
Hoisting
作用域內提升
這是一個相對簡單但又容易踩坑的地方。在ES6之前,所謂的變量提升即 JS會將所有的變量和函數聲明移動到它所在作用域的最前面。這裏有兩個重要的信息:
(1)只將變量或函數的聲明提前,而賦值並未被提前
(2)只提前到變量或函數所在作用域的最前面,而不是全局作用域的最前面知道這兩個要點,就不會再踩坑了。另外ES6中的
let
和const
具有TDZ(暫時死區)
的效果,不再本次討論範圍內。拿個案例來加深一下印象
console.log(a) // ReferenceError: a is not defined
(function() {
console.log(a); // undefined
say(); // NaN
var a = 10;
function say() {
var b = 15;
console.log(a + b)
}
console.log(a); // 10
say(); // 25
})()
解釋:
因爲立即執行函數具有隔離作用域的作用,而外部未定義有變量a,因此第一行執行會報錯。
在自執行函數作用域內,定義有var a = 10
,由於變量提升效果,a
的聲明會被提前至函數作用域的最上方,但賦值不會被提升,因此第二個a
打印undefined
.
函數提升效果同理,但在執行第一個say()
時,因爲此時的a
還是沒有賦值,還是undefined
,因此輸出NaN
最底下兩個打印正常不解釋
- 閉包:
closure
一個可以訪問私有作用域的函數及其所在的運行環境的組合
所謂的閉包是指能訪問私有作用域的函數及創建該函數的詞法環境的組合, 這個環境包含了這個閉包創建時所能訪問的所有局部變量。第一次看到這句話的時我的內心是拒絕的,不僅難理解,還繞口很難讀。但這個東西還真的挺重要。到底什麼意思?結合一個案例來理解
function func(){
var n = 0; // n是func函數的局部變量
function closure() { // closure是func函數的內部函數,是閉包
n += 1; // 內部使用了外部函數中的變量n
console.log(n);
}
return closure;
}
var counter= func();
counter(); // 1
counter(); // 2
counter(); // 3
解釋:
這裏利用閉包實現了一個計數器功能,它有什麼好處?相比於普通變量定義的計數器,這個計數器只能通過調用
counter
來實現數量n
的累加,再沒有別的方法可以改變n
的值,這樣就保證了計數器正確穩定的計數杜絕外部干擾和破壞。這是閉包的一大優勢。爲什麼有這效果?聽說會造成內存泄漏是怎麼回事?我們得先理解閉包定義的這句話。分析一下案例,
func
是一個普通函數,在它的私有作用域中,定義了一個變量n
,和一個closure
函數,最後將這個函數返回。單從
func
出發,在其私有作用域中有兩種性質的變量或方法:(1)私有的即外部不可訪問的;(2)被暴露可訪問的即被return
的。關於這點一會模塊化裏還會再提到。而閉包要做的事就是將函數私有作用域內外部不可訪問的變量或方法讓外界可以訪問,實現這個思想的主要手段就是通過被暴露的方法來導出私有變量,這樣做是爲了避免變量污染全局同時也避免被污染,同時又可以在全局中被引用和改變更新,通過這種方式定義的變量來做以上計數器的功能,是很被放心的。
然而閉包雖好用,卻容易造成內存泄漏問題。瀏覽器自身有垃圾回收機制(GC),即引用數爲0的變量或方法所佔用的內存空間會被釋放回收。通常情況下,一個普通函數執行結束後,因爲其私有作用域效果,其內部的所有變量和函數的引用數立刻下降爲0,GC會立刻回收這部分內容。 而閉包因爲其特性,即使本函數執行結束,也可能有某個外部方法仍然調用着內部的變量導致引用數不爲0,造成GC無法回收內存也就是所說的內存泄漏
回到案例,當執行完第一個
counter()
打印1
後,我們理解函數執行完畢,變量n
應該被GC回收而不存在,但當我們繼續執行第二個counter()
後發現,打印值是2
不是1
,也就是說,這個func
的私有變量n
一直被counter
引用着,因此實現了計數累加。這也就是產生內存泄漏的根源所在。
- 使用閉包定義私有變量:變量私有化
通常開發者們會用下劃線定義私有變量,但從嚴格意義上來說這並不準確。閉包能真正做到定義私有變量
function Product () {
var name; // 函數內的私有變量
this.setName = function (v) {
name = v;
};
this.getName = function () {
return name;
};
}
var person = new Product();
person.setName('zfs');
console.log(person.name); // undefined
console.log(person.getName()); // ‘zfs’
解釋:
name
是函數內的私有變量,外界不可訪問,因此person.name()
爲undefined
,而getName()
在函數內部能訪問到name
值,因此得到zfs
。
這很像一個工廠函數,只是工廠函數通常都會將所有的變量和函數都暴露出來,當然這也不是絕對的,我們同樣可以認爲這就是一個工廠函數
- 模塊化:作用域獨立化及私有化
在ES6之前,JavaSrcipt並不是模塊化語言,後來有了AMD(
Asynchronous Module Definition
異步模塊定義)規範和RequireJs規範,使得JS也能實現模塊化編程。直到2015年ES6的出現,JS的模塊化變得更加重要和流行。想了解更多模塊化知識,可閱讀模塊化編程 一文
定義一個模塊的方式有很多種,既然要成爲一個模塊,它必就必須要有私有作用域。可以實例化一個對象,用一個獨立的文件,或者一個立即執行函數等方法來實現。借鑑大家所熟知的JQuery庫,有如下案例
var module = (function() {
let _count = 3;
function print () {
console.log(`now count is ${_count }`)
}
function plus (x = 1) {
_count = x + _count
print(x)
}
return {
desc: 'this is a module sample',
plus: plus
}
})();
console.log(module.desc); // "this is a module sample"
module.plus(2); // now count is 5
解釋:
稍有用心你會發現,一個模塊通常會有幾個特性:(1)有個獨立的作用域空間,形成模塊;(2)有一些私有的方法或變量,用於處理模塊內的邏輯,而這些邏輯對外界透明外界也不需要關心;(3)會暴露出一些方法或變量,用於提供外界訪問的接口。
模塊化的最優實現方案是控制好自己的私有作用域,隱藏外界不需要關心的變量和處理邏輯,同時不被污染和意外的修改,這與閉包思想一致。如下案例便不是一個好的辦法
- 柯里化:定義多參數函數增加函數使用靈活性
柯里化,即 Currying,目的是爲了提高函數的靈活性。常規的函數只有一個參數,在學習了閉包的思想後我們得知可以在函數內部返回一個函數,但我們可以一次性傳入多個參數,方案如下
var plus = function (a) {
return function (b) {
console.log(a + b);
}
}
var add5 = plus(5);
var add8 = plus(8);
plus(20)(15); // 35
add5(10); // 15
add8(10); // 18
看完案例是不是覺得很簡單?定義一個類似
plus
函數我們可以先傳入一個參數,處理一些公用的基礎的邏輯,得到一個帶有功能的函數體,在用第二個參數來處理各個業務流程不同的需求。避免了重複實現一些基礎邏輯的部分。
- 構造函數:又叫工廠函數,能產生隔離作用域,爲生成具有特定功能的實例
JS中不存在類,至少在ES6之前是不存在的。此之前,實現這個相應的功能則是通過構造函數和原型鏈來實現的。
通常實現一個構造函數有一下幾個特點:
(1)構造函數函數名首字母建議(必須)大寫,用來區分與普通函數的區別。
(2)內部屬性使用this
來指向即將要生成的屬性;
(3)使用new
關鍵字來生成實例對象
var Person = function () {
this.name = 'zfs';
this.age = 25
this.intro = function () {
console.log(this.name + ' age ' + this.age)
}
}
var p1 = new Person()
console.log(p1.name) // zfs
p1.intro () // zfs age 25
所有的實例對象都可以繼承構造器函數中的屬性和方法,但是,不同實例對象之間的屬性和方法相對獨立,無法共享數據。要解決這個問題,需要使用到構造函數的
prototype
原型屬性
- 原型 prototype: 實例對象的共享屬性,常爲方法
先思考一個問題:
let obj = Product()
和let obj = new Product()
這兩者怎麼理解?有什麼區別?
前者是將函數Product
的運行返回值賦給變量obj
,後者做的是 調用構造函數,創建一個包含prototype
內部指針的新對象obj
。實際上:每個JavaScript構造函數都有一個
prototype
屬性,用於設置所有實例對象需要的共享屬性和方法。被聲明在prototype
中的方法和屬性不能被枚舉,也不能通過hasOwnProperty()
判斷,判斷對象是否含有某個原型屬性需要使用in
關鍵字關於
prototype
個人認爲W3C的說法並不太容易理解反而有點容易混淆,如下相信很多新手朋友會有如下誤解
prototype 屬性使您有能力向對象添加屬性和方法。
語法:Object.prototype.name = value
var obj = {
name: 'zfs',
age: 25
}
obj.prototype.hobby = 'basketball'
// TypeError cannot set property `hobby` of undefined
沒錯啊,根據W3C的講解,我給對象obj設置了原型屬性
hobby
,爲什麼報錯了?會這麼做的朋友,大都是沒有好好理解W3C語法那半句。沒有注意到語法中所謂的對象,其實是“
Object
”,它是什麼?原生能力較好的朋友應該都清楚,這其實是對象的構造器,我們可以通過它來實例化出實例對象,即
let obj = new Object()
prototype
是構造函數上用來設置共享屬性和方法的屬性,上述錯誤主要是錯在將prototype
設置在了實例對象上了,因此編譯器拋出異常。正確的用法應該是如下所示:
var someone = {}
// 需要在構造函數上設置原型屬性
Object.prototype.hobby = 'basketball'
// in 能遍歷出原型屬性,而hasOwnProperty() 則不能
for (let s in someone) {
console.log(s) // hobby
}
prototype
在構造函數上的應用,更加豐富和值得大家學習掌握
// 創建構造函數
function Person (name = 'sg') {
this.name = name; // 實例屬性 name
this.visited= []; // 實例屬性 travel
this.sayHi = function () { // 實例方法 sayHi()
console.log(`Hello, ${name}`)
}
}
Person.prototype.city = 'beijing' // 原型屬性 city
Person.prototype.travel = function (place) {
this.visited.push(place)
console.log(this.visited)
}
let zfs = new Person('zfs')
let borui = new Person('borui')
console.log(zfs.name) // 'zfs'
zfs.sayHi() // Hello, zfs
console.log(zfs.city); // beijing
zfs.travel('shanghai'); // ['shanghai']
console.log(borui.name) // borui 實例屬性值各自私有
console.log(borui.city) // beijing 實例屬性被共享
zfs.travel('chongqin') // ['shagnhai', 'chongqin']
// 原型共享屬性中,當其中一個實例對象的屬性值被修改,不會影響其他實例對象。
zfs.city = 'fujian';
console.log(zfs.city); // fujian
console.log(borui.city); // beijing
總結一下:
prototype
是設置在構造函數中的用來創建共享屬性和方法的特殊屬性,用它設置的屬性和方法可以被所有的實例對象繼承,不同實例對象中的屬性修改不會相互影響。
【擴展】
另外,JS屬性大家還應該知道三個:(1)靜態屬性;(2)實例屬性;(3)原型屬性
// 靜態屬性
function Person () { }
let zfs = new Person()
Person.age = '25'
// 靜態屬性只能通過 `類名.屬性` 形式來訪問,無法通過實例訪問
console.log(Person.age) // 25
console.log(zfs.age) // undefined
// 實例屬性
funciton Person () {
this.name = 'zfs'; // 用this 引用,能夠被實例對象直接繼承的屬性
}
function Person() { }
Person.prototype.name = 'zfs'; // 使用原型定義的屬性稱爲共享屬性
- 自定義對象:產生隔離作用域,爲實現某些特定功能而定義的對象
曾有次面試被問到,瞭解“自定義對象”嗎? 答: 瞭解,
var obj = new Object()
.... 然後很自然就被“回去等消息”了。其實回答的也沒錯,只是自定義對象的內容遠不止這些。方法很多如(1)字面量法;(2)new 構造函數;(3)ES5中 Object.create(prototype, propertyDescriptor) .....
這裏我們只講解前兩種,第三種並不太好用字面量法很簡單,也是初學程序員最常用的
var obj = {
name: 'zfs',
age: 25
}
Object.prototype.sex = 'male'
console.log(obj) // { name:'zfs', age: 25, sex:'male' }
而真正的自定義變量精髓就在於使用構造函數,定製化的創造出我們需要的“類”。利用構造函數生成實例對象。本人回答的
var obj = new Object()
也正是這種辦法中的一種,要達到定製化效果,還是需要自己創建構造函數
// 根據自己需求創建構造函數
function People(name) {
this.name = name;
}
let zfs = new Person('zfs')
console.log(zfs.name) // zfs
cosole.log(zfs.constructor.prototype == Person.prototype) // true
自定義對象實現多層繼承。此時
constructor
返回最先調用的構造函數
function People (name) {
this.name = name
}
function Student(sex) {
this.sex = sex;
}
// 設置Student的原型爲People對象
Student.prototype = new People()
var s = new Student(25) // 對象初始化時,先調用People(), 再調用Student()
console.log(s.constructor) // function People 對象s的構造函數是Peopel()
console.log(s.constructor.prototype) // People()
console.log(s.constructor.prototype == People.prototype) // true
用法大致如上,至於如何實現自己的需求,關鍵在於創建好自己的構造函數
- apply, call 和 bind方法:他們的異同及如何使用
每個函數都包含兩個非繼承過來的方法,
apply()
和call()
,他們都是Function.prototype
的方法。
call
和apply
被用來調用函數,它們都能改變this
的指向,指定對象(第一個參數)替換函數的this
值。他們的作用是實現多重繼承。他們也可以不指定參數,此時只是單純的調用函數如func.call()
。他們兩功能相同,區別在於call()
的參數需要一一列出;apply
除第一個this對象
參數外,其餘參數要封裝在一個arguments
數組中
1. B.call(A,arg1, arg2): A對象調用B對象的方法,或者說B對象中的方法在A的
this
環境下運行。參數需要一一列出
// 定義一個自定義對象,包含可被繼承的方法
var person = {
name: 'name',
sayHi: function (args1, args2) {
console.log(`Hi ${this.name}`);
console.log(`args1:${args1}, args2:${args2}`)
}
}
// 欲實現繼承的對象本身
var zfs = {
name: 'zfs'
}
person.sayHi.call(zfs, 'p1', 'p2')
// ---------output-----------
// Hi zfs
// args1: p1,args2: p2
解釋:
person
對象的方法調用call()
後執行時的this
對象被改變成了zfs
,因此輸出Hi zfs
而不是Hi name
,而zfs
對象本身沒有sayHi()
方法,也通過call()
成功繼承了該方法,這就是call()
方法的作用。
2.B.apply(A, arguments): A對象調用B對象的方法,參數使用數組形式傳遞,也就是說除第一個參數外,其他參數封裝在一個數組內。
var Greeting= {
greet: 'Good morning!',
say: function (a, b, c) {
console.log(`${this.greet} ${a}, ${b}, ${c}`)
}
}
var afternoon= {
name: 'Good afternoon! '
}
Greeting.say.apply(afternoon, ['zfs', 'borui', 'laic'])
// -----------output-------------
// Good morning! zfs, borui, laic
解釋:
雖然
apply()
的參數是數組形式傳遞的,但原型函數依舊是多個參數接收。要注意自定義對象的定義並不受調用函數影響,只有調用函數去適應自定義函數本身。
3.B.bind(A, arg1, agr2): 與前兩者不同,它可以爲函數綁定
this
值,然後作爲一個新的函數返回,並且不會立即執行該函數。其餘與call()
類似
var greet = {
name: 'name',
say: function (greet) {
console.log(`${greet} ${this.name}`)
}
}
const zfs = {
name: 'zfs'
}
const borui = {
name: 'borui'
}
// 定義時並不會立即執行,而是以函數形式返回
let greetZfs = greet.say.bind(zfs, 'Good morning!')
let greetBorui = greet.say.bind(borui)
greetZfs() // Good morning! zfs
greetBorui('Hello') // Hello borui
greetZfs('Hi') // Good morning zfs
解釋:
案例中,如
greetZfs
得到的其實是greet
對象中被改變了this
指向的say()
函數本身,而不是say()
的執行結果。對比一下第一個和第三個打印會發現輸出都是Good morning zfs
而不是Hi zfs
,即bind()
方法本身參數優先級高於結果函數傳入參數的優先級
- Memoization:優化耗時計算方案,常用作處理遞歸緩存
Memoize 用於優化比較耗時的計算,通過計算將結果緩存到內存中,這樣對於同樣的輸入值,下次只需要從內存中獲取即可。數學上有個經典的斐波那契數列,如下
【注】
arguments
用於保存函數參數,callee
是arguments
的一個屬性,返回正在被調用的函數對象。這有利於函數的遞歸和保證函數的封裝性
var fibonacci = function (n) {
return n < 2 ? n : arguments.callee(n - 1) + arguments.callee(n - 2);
}
console.log(fibonacci(8))
隨着係數
n
的不斷加大,運行時間也隨之增加,頁面性能急速下降。當增加到40時,ff和 ie開始進入僵死狀態,UI線程被阻塞!頁面卡死。著名的underscore 的Memoize
方法,成功解決了該問題
var fibonacci = _.memoize(function (n) {
return n < 2 ? n : arguments.callee(n - 1) + arguments.callee(n - 2);
})
console.log(fibonacci(40))
// 源碼:已參數作爲鍵進行緩存,利用空間換cpu運行時間
_.memoize = function(func, hasher) {
var memoize = function (key) {
var cache = memoize.cache;
var address = hasher ? hasher.apply(this, arguments) : key
if (!_.has(cache, address)) {
cache[address] = func.apply(this, arguments)
}
return cache(address)
}
memoize.cache = {};
return memoize;
}
換個比較容易理解的寫法如下
function memoizeFunc (func) {
var cache = {};
return function () {
var key = arguments[0];
if (!cache[key]) {
cache[key] = func.apply(this, arguments)
}
return cache[key]
}
}
這樣,我們就不擔心頁面卡死問題,該類思想適合處理遞歸問題。也玩玩遞歸現象比較容易出現大量的計算量
- 函數重載: 允許函數有不同輸入,並返回不同的結果
所謂函數重載(method overloading) 即函數名稱一樣,但輸入輸出不一樣。也就是說允許函數有各種不同的輸入,根據不同的輸入,返回的結果不同。jQuery之父John Resig提出了一個非常巧(bian)妙(tai)的方法,
利用了閉包
。假設有如下一個需求,有一個
people
對象,裏面存着一些人名,如下:
var people = {
value: ["Dean Edwards", "Sam Stephenson", "Alex Russell", "Dean Tom"]
}
我們希望
people
對象中擁有一個find
方法,當不傳任何參數時,就會把people.value
裏面的所有元素返回來,當傳入一個參數時,就把first-name
跟這個參數匹配的元素返回來,當傳兩個參數時,則把first-name
和last-name
都匹配的值才返回來。這個find
方法是根據參數的個數不同而執行不同的操作,所以我們希望有一個addMethod
方法,能夠如下的爲people
添加find
重載
addMethod(people, "find", function() {}); /*不傳參*/
addMethod(people, "find", function(a) {}); /*傳一個*/
addMethod(people, "find", function(a, b) {}); /*傳兩個*/
難點在於如何實現這個
addMethod()
方法,John Resig的實現方法如下:思路是當綁定find_2(兩個參數,下同)時,old
爲find_1,當綁定find_1時,old
爲find_0;利用這種閉包調用的思想實現了不同處理函數的鏈接。
function addMethod (object, name, fn) {
// 把前一次添加的方法存在一個臨時變量old裏面
var old = object[name];
// 重寫object[name]的方法
object[name] = function () {
// 如果調用object[name]方法時,傳入的參數個數跟預期的一致,則直接調用
if (fn.length === arguments.length ) {
return fn.apply(this, arguments)
} else if (typeof old === "function") { // old如果是函數,調用
return
}
}
}
fn.length
表示函數形參的個數,即函數定義時參數的個數。見如下【拓展】
實現了
addMethod
方法,接下來開始實現people.find
方法的重載
// 當不傳遞參數時,返回`people.values`裏面的所有元素
addMethod(people, 'find', function() {
return this.values
})
// 傳入一個參數時,按first-name的匹配進行返回
addMethod(people, "find", function() {
var ret = [];
for (let i in this.values) {
this.values[i].indexOf(firstName) === 0 && ret.push(this.value[i]);
}
return ret;
})
// 傳入兩個參數時,返回first-name和last-name都匹配的元素
addMethod(people, "find", function(firstName, lastName) {
var ret = []
for (let i in this.values) {
this.values[i] === (`${firstName} ${lastName}`) && ret.push(this.values[i])
}
return ret;
})
這樣我們就實現了find的函數根據不同輸入返回不同結果的功能,測試驗證一下
// 分別傳入0, 1, 2個參數測試
console.log(people.find()); // ["Dean Edwards", "Alex Russell", "Dean Tom"]
console.log(people.find("Dean")); // ["Dean Edwards", "Dean Tom"]
console.log(people.find("Dean Edwards")); // ["Dean Edwards"]
【拓展】
fn.length
與arguments.length
的區別這兩者均表示函數參數的個數,但指代不同。
fn.length
表示函數形參的個數,即函數定義是參數的個數。arguments.length
表示函數實參的個數,表示函數調用時傳遞進來的參數個數。如下案例
function find (a,b,c,d) {
console.log(arguments.length) // 9
}
find(1,2,3,4,5,6,7,8,9)
console.log(find.length) // 4