能够手撕各种JavaScript原生函数,可以说是进大厂必备!同时对JavaScript源码的学习和实现也能帮助我们快速扎实地提升自己的前端编程能力。
最近很多人和我一样在积极地准备前端面试笔试,所以就整理了一些前端面试笔试中非常容易被问到的原生函数实现和各种前端原理实现,其中部分源码戳这里。
我们首先知道new做了什么:
知道new做了什么,接下来我们就来实现它
function create(Con, ...args){
// 创建一个空的对象
this.obj = {};
// 将空对象指向构造函数的原型链
Object.setPrototypeOf(this.obj, Con.prototype);
// obj绑定到构造函数上,便可以访问构造函数中的属性,即this.obj.Con(args)
let result = Con.apply(this.obj, args);
// 如果返回的result是一个对象则返回
// new方法失效,否则返回obj
return result instanceof Object ? result : this.obj;
}
思路很简单,就是利用Object.prototype.toString
Array.myIsArray = function(o) {
return Object.prototype.toString.call(Object(o)) === '[object Array]';
};
function create = function (o) {
var F = function () {};
F.prototype = o;
return new F();
};
真实经历,最近在字节跳动的面试中就被面试官问到了,要求手写实现一个简单的Event类。
class Event {
constructor () {
// 储存事件的数据结构
// 为查找迅速, 使用对象(字典)
this._cache = {}
}
// 绑定
on(type, callback) {
// 为了按类查找方便和节省空间
// 将同一类型事件放到一个数组中
// 这里的数组是队列, 遵循先进先出
// 即新绑定的事件先触发
let fns = (this._cache[type] = this._cache[type] || [])
if(fns.indexOf(callback) === -1) {
fns.push(callback)
}
return this
}
// 解绑
off (type, callback) {
let fns = this._cache[type]
if(Array.isArray(fns)) {
if(callback) {
let index = fns.indexOf(callback)
if(index !== -1) {
fns.splice(index, 1)
}
} else {
// 全部清空
fns.length = 0
}
}
return this
}
// 触发emit
trigger(type, data) {
let fns = this._cache[type]
if(Array.isArray(fns)) {
fns.forEach((fn) => {
fn(data)
})
}
return this
}
// 一次性绑定
once(type, callback) {
let wrapFun = () => {
callback.call(this);
this.off(type, wrapFun); // 执行完以后立即解绑
};
this.on(type, wrapFun); // 绑定
return this;
}
}
let e = new Event()
e.on('click',function(){
console.log('on')
})
// e.trigger('click', '666')
console.log(e)
先回忆一下Array.prototype.reduce语法:
Array.prototype.reduce(callback(accumulator, currentValue[, index[, array]])[, initialValue])
然后就可以动手实现了:
Array.prototype.myReduce = function(callback, initialValue) {
let accumulator = initialValue ? initialValue : this[0];
for (let i = initialValue ? 0 : 1; i < this.length; i++) {
let _this = this;
accumulator = callback(accumulator, this[i], i, _this);
}
return accumulator;
};
// 使用
let arr = [1, 2, 3, 4];
let sum = arr.myReduce((acc, val) => {
acc += val;
return acc;
}, 5);
console.log(sum); // 15
先来看一个call实例,看看call到底做了什么:
let foo = {
value: 1
};
function bar() {
console.log(this.value);
}
bar.call(foo); // 1
从代码的执行结果,我们可以看到,call首先改变了this的指向,使函数的this指向了foo,然后使bar函数执行了。总结一下:
思考一下:我们如何实现上面的效果呢?代码改造如下:
Function.prototype.myCall = function(context) {
context = context || window;
//将函数挂载到对象的fn属性上
context.fn = this;
//处理传入的参数
const args = [...arguments].slice(1);
//通过对象的属性调用该方法
const result = context.fn(...args);
//删除该属性
delete context.fn;
return result
};
我们看一下上面的代码:
以此类推,我们顺便实现一下apply,唯一不同的是参数的处理,代码如下:
Function.prototype.myApply = function(context) {
context = context || window
context.fn = this
let result
// myApply的参数形式为(obj,[arg1,arg2,arg3]);
// 所以myApply的第二个参数为[arg1,arg2,arg3]
// 这里我们用扩展运算符来处理一下参数的传入方式
if (arguments[1]) {
result = context.fn(…arguments[1])
} else {
result = context.fn()
}
delete context.fn;
return result
};
以上便是call和apply的模拟实现,唯一不同的是对参数的处理方式。
function Person(){
this.name="zs";
this.age=18;
this.gender="男"
}
let obj={
hobby:"看书"
}
// 将构造函数的this绑定为obj
let changePerson = Person.bind(obj);
// 直接调用构造函数,函数会操作obj对象,给其添加三个属性;
changePerson();
// 1、输出obj
console.log(obj);
// 用改变了this指向的构造函数,new一个实例出来
let p = new changePerson();
// 2、输出obj
console.log(p);
仔细观察上面的代码,再看输出结果。
我们对Person类使用了bind将其this指向obj,得到了changeperson函数,此处如果我们直接调用changeperson会改变obj,若用new调用changeperson会得到实例 p,并且其__proto__指向Person,我们发现bind失效了。
我们得到结论:用bind改变了this指向的函数,如果用new操作符来调用,bind将会失效。
这个对象就是这个构造函数的实例,那么只要在函数内部执行 this instanceof 构造函数 来判断其结果是否为true,就能判断函数是否是通过new操作符来调用了,若结果为true则是用new操作符调用的,代码修正如下:
// bind实现
Function.prototype.mybind = function(){
// 1、保存函数
let _this = this;
// 2、保存目标对象
let context = arguments[0]||window;
// 3、保存目标对象之外的参数,将其转化为数组;
let rest = Array.prototype.slice.call(arguments,1);
// 4、返回一个待执行的函数
return function F(){
// 5、将二次传递的参数转化为数组;
let rest2 = Array.prototype.slice.call(arguments)
if(this instanceof F){
// 6、若是用new操作符调用,则直接用new 调用原函数,并用扩展运算符传递参数
return new _this(...rest2)
}else{
//7、用apply调用第一步保存的函数,并绑定this,传递合并的参数数组,即context._this(rest.concat(rest2))
_this.apply(context,rest.concat(rest2));
}
}
};
Currying的概念其实并不复杂,用通俗易懂的话说:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
function progressCurrying(fn, args) {
let _this = this
let len = fn.length;
let args = args || [];
return function() {
let _args = Array.prototype.slice.call(arguments);
Array.prototype.push.apply(args, _args);
// 如果参数个数小于最初的fn.length,则递归调用,继续收集参数
if (_args.length < len) {
return progressCurrying.call(_this, fn, _args);
}
// 参数收集完毕,则执行fn
return fn.apply(this, _args);
}
}
防抖函数 onscroll 结束时触发一次,延迟执行
function debounce(func, wait) {
let timeout;
return function() {
let context = this; // 指向全局
let args = arguments;
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func.apply(context, args); // context.func(args)
}, wait);
};
}
// 使用
window.onscroll = debounce(function() {
console.log('debounce');
}, 1000);
节流函数 onscroll 时,每隔一段时间触发一次,像水滴一样
function throttle(fn, delay) {
let prevTime = Date.now();
return function() {
let curTime = Date.now();
if (curTime - prevTime > delay) {
fn.apply(this, arguments);
prevTime = curTime;
}
};
}
// 使用
var throtteScroll = throttle(function() {
console.log('throtte');
}, 1000);
window.onscroll = throtteScroll;
乞丐版
JSON.parse(JSON.stringfy));
非常简单,但缺陷也很明显,比如拷贝其他引用类型、拷贝函数、循环引用等情况。
基础版
function clone(target){
if(typeof target === 'object'){
let cloneTarget = {};
for(const key in target){
cloneTarget[key] = clone(target[key])
}
return cloneTarget;
} else {
return target
}
}
写到这里已经可以帮助你应付一些面试官考察你的递归解决问题的能力。但是显然,这个深拷贝函数还是有一些问题。
一个比较完整的深拷贝函数,需要同时考虑对象和数组,考虑循环引用:
function clone(target, map = new WeakMap()) {
if(typeof target === 'object'){
let cloneTarget = Array.isArray(target) ? [] : {};
if(map.get(target)) {
return target;
}
map.set(target, cloneTarget);
for(const key in target) {
cloneTarget[key] = clone(target[key], map)
}
return cloneTarget;
} else {
return target;
}
}
原理: L 的 proto 是不是等于 R.prototype,不等于再找 L.__proto__.__proto__ 直到 proto 为 null
// L 表示左表达式,R 表示右表达式
function instance_of(L, R) {
var O = R.prototype;
L = L.__proto__;
while (true) {
if (L === null){
return false;
}
// 这里重点:当 O 严格等于 L 时,返回 true
if (O === L) {
return true;
}
L = L.__proto__;
}
}
function myExtend(C, P) {
var F = function(){};
F.prototype = P.prototype;
C.prototype = new F();
C.prototype.constructor = C;
C.super = P.prototype;
}
就是利用 generator(生成器)分割代码片段。然后我们使用一个函数让其自迭代,每一个yield 用 promise 包裹起来。执行下一步的时机由 promise 来控制
function _asyncToGenerator(fn) {
return function() {
var self = this,
args = arguments;
// 将返回值promise化
return new Promise(function(resolve, reject) {
// 获取迭代器实例
var gen = fn.apply(self, args);
// 执行下一步
function _next(value) {
asyncGeneratorStep(gen, resolve, reject, _next, _throw, 'next', value);
}
// 抛出异常
function _throw(err) {
asyncGeneratorStep(gen, resolve, reject, _next, _throw, 'throw', err);
}
// 第一次触发
_next(undefined);
});
};
}
最近字节跳动的前端面试中也被面试官问到,要求手写实现。
Array.prototype.myFlat = function(num = 1) {
if (Array.isArray(this)) {
let arr = [];
if (!Number(num) || Number(num) < 0) {
return this;
}
this.forEach(item => {
if(Array.isArray(item)){
let count = num
arr = arr.concat(item.myFlat(--count))
} else {
arr.push(item)
}
});
return arr;
} else {
throw tihs + ".flat is not a function";
}
};
这个问题一般还会让你讲一讲事件冒泡和事件捕获机制
<ul id="color-list">
<li>red</li>
<li>yellow</li>
<li>blue</li>
<li>green</li>
<li>black</li>
<li>white</li>
</ul>
<script>
(function () {
var color_list = document.getElementById('color-list');
color_list.addEventListener('click', showColor, true);
function showColor(e) {
var x = e.target;
if (x.nodeName.toLowerCase() === 'li') {
alert(x.innerHTML);
}
}
})();
</script>
Vue 2.x的Object.defineProperty版本
// 数据
const data = {
text: 'default'
};
const input = document.getElementById('input');
const span = document.getElementById('span');
// 数据劫持
Object.defineProperty(data, 'text', {
// 数据变化 —> 修改视图
set(newVal) {
input.value = newVal;
span.innerHTML = newVal;
}
});
// 视图更改 --> 数据变化
input.addEventListener('keyup', function(e) {
data.text = e.target.value;
});
Vue 3.x的proxy 版本
// 数据
const data = {
text: 'default'
};
const input = document.getElementById('input');
const span = document.getElementById('span');
// 数据劫持
const handler = {
set(target, key, value) {
target[key] = value;
// 数据变化 —> 修改视图
input.value = value;
span.innerHTML = value;
return value;
}
};
const proxy = new Proxy(data, handler);
// 视图更改 --> 数据变化
input.addEventListener('keyup', function(e) {
proxy.text = e.target.value;
});
思考:Vue双向绑定的实现,使用 ES6 的 Proxy 相比 Object.defineProperty 有什么优势?
先看看reduce和map的使用方法
let new_array = arr.map(function callback(currentValue[, index[,array) {}[, thisArg])
let result = arr.reduce(callback(accumulator, currentValue[, index[, array]])[, initialValue])
最常见的方式我们可以用一个for循环来实现:
Array.prototype.myMap = function(callback, thisArg) {
let arr = [];
for (let i = 0; i < this.length; i++) {
arr.push(callback.call(thisArg, this[i], i, this));
}
return arr;
};
同样的我们也可以用数组的reduce方法实现
Array.prototype.myMap2 = function(callback, thisArg) {
let result = this.reduce((accumulator, currentValue, index, array) => {
accumulator.push(callback.call(thisArg, currentValue, index, array));
return accumulator;
}, []);
return result;
};
看完如果觉得对你有帮助,劳烦点个赞哈,你的鼓励就是我更新最大的动力!
学习使我快乐!
么是函数?
把一段相对独立的具有特定功能的代码块封装起来,形成一个独立实体,就是函数,起个名字(函数名),在后续开发中可以反复调用。函数的作用就是封装一段代码,将来可以重复使用。推荐了解黑马程序员web前端课程。
为什么要使用函数?为了解决代码的重用!减少代码量。
函数的分类
系统内置函数 和 程序员自定义函数
定义函数
function 函数名([参数列表]){
函数体
}
结构说明:
·function它是定义函数的关键字 不可以省略。
·函数名它的命名规则与变量名是一样的
·函数名后面紧跟着一对小括号 这一对小括号不能省略
·小括号里面可能有参数,我们将其称之为形参
·小括号后面紧跟着一对大括号 这一对大括号不能省略
·大括号里面是函数体
注意:函数定义了一定要调用函数
调用函数
格式:
函数名([实参列表])
注意:在定义函数时如果有形参 反过来说 在调用的时候也要有实参 但是这个不是绝对的!
同名函数后面的会将前面的覆盖
函数一定是先定义后再调用
函数的参数
函数的参数分为两种:
形式参数和实际参数
形式参数:在定义函数的时候的参数就称之为形式参数,简称“形参”。在定义函数的时候 在函数名的后面的小括号里面给的变量名。
实际参数:在调用函数的时候的参数就称之为实际参数,简称“实参”。
在一个函数中,参数的多少是根据功能来定义的!
使用函数来动态的输出M行N列的表格
一般在函数体里面不会有输出语句,只会有一个return关键字,将我们要输出的内容返回给函数的调用者。
·return在英文中表示“返回”的意思
·return关键字是在函数体里面使用。
它在函数体使用有两层含义:
2.它会向函数的调用者返回数据(重点)返回值
格式:return数据;
在调用函数时可以定义一个变量要接收到这个函数的返回值
注意:
我们在以后的工作中,函数体里面尽量不要有输出语句(document.write alert console.log ),尽量的使用return关键字将数据返回给函数的调用者。
特别强调:
·在一个函数里面,return关键字可以有多个,但是return只会执行一次;
·return关键字它只能返回一个数据,如果需要返回多个数据,我们可以将多个数据使用数组的方式来保存,然后再将数组返回。
匿名函数
什么是匿名函数?
没有名字的函数 称之为匿名函数!注意:匿名函数在JS中使用最多。
匿名函数也是需要调用的!
将匿名函数赋值给变量或者是事件
a)将匿名函数赋值给变量,然后通过变量名加上小括号来调用匿名函数
b)将匿名函数赋值给事件 *****
将匿名函数赋值给事件,那么匿名函数什么时候才会执行?它要等到事件触发了以后,匿名函数才会执行。
什么是变量的作用域?
指变量在什么地方可以使用,什么地方不可以使用。
变量作用域的分类
变量作用域分为:全局作用域和局部作用域。
变量的作用域是通过函数来进行划分的。
在函数外面定义的变量我们将其称为全局变量,它的作用域是全局的。
全局作用域: 在任何地方都可以访问到的变量就是全局变量,对应全局作用域
局部作用域: 在固定的代码片段内可访问到的变量,最常见的例如函数内部。对应局部作用域(函数作用域)
问:是否能够提升局部变量的作用域呢?将局部变量的作用域提升至全局作用域。在函数里面定义的变量也能够在函数外面访问到。
只需要将函数里面定义的变量的var关键字给去掉就可以实现将局部变量的作用域提升至全局作用域。
但是:并不建议直接就var 关键字给省略,我们建议在函数的外面定义一个同名的全局变量。
击标题下「异步社区」可快速关注
本文包括以下内容:
理解函数为何如此重要
函数为何是第一类对象
定义函数的方式
参数赋值之谜
在本文这一部分讨论JavaScript基础时,也许你会感到惊讶,我们的第一个论点是函数(function)而非对象(object)。当然,第3部分会用大量笔墨解释对象,但归根结底,你要理解一些基本事实,像普通人一样编写代码和像“忍者”一样编写代码的最大差别在于是否把JavaScript作为函数式语言(functional language)来理解。对这一点的认知水平决定了你编写的代码水平。
如果你正在阅读这本文,那么你应该不是一位初学者。对于后续内容,我们假设你已经足够了解面向对象基础(当然,我们会在以后章节详细讨论对象的高级概念),但真正理解JavaScript中的函数才是你能使用的唯一一件重要武器。函数是如此重要,所以本文及接下来两章将带领你彻底理解JavaScript中的函数。
JavaScript中最关键的概念是:函数是第一类对象(first-class objects),或者说它们被称作一等公民(first-class citizens)。函数与对象共存,函数也可以被视为其他任意类型的JavaScript对象。函数和那些更普通的JavaScript数据类型一样,它能被变量引用,能以字面量形式声明,甚至能被作为函数参数进行传递。本文一开始会介绍面向函数编程带来的差异,你会发现,在需要调用某函数的位置定义该函数,能让我们编写更紧凑、更易懂的代码。其次,我们还会探索如何把函数用作第一类对象来编写高性能函数。你能学到多种不同的函数定义方式,甚至包括一些新类型,例如箭头(arrow)函数,它能帮你编写更优雅的代码。最后,我们会学习函数形参和函数实参的区别,并重点关注ES6的新增特性,例如剩余参数和默认参数。
让我们通过了解函数式编程的优点来开始学习吧。
你知道吗?
回调函数在哪种情况下会同步调用,或者异步调用呢?
箭头函数和函数表达式的区别是什么?
你为什么需要在函数中使用默认参数?
1.1 函数式的不同点到底是什么
函数及函数式概念之所以如此重要,其原因之一在于函数是程序执行过程中的主要模块单元。除了全局JavaScript代码是在页面构建的阶段执行的,我们编写的所有的脚本代码都将在一个函数内执行。
由于我们的大多数代码会作为函数调用来执行,因此,我们在编写代码时,通用强大的构造器能赋予代码很大的灵活性和控制力。本文的大部分内容解释了如何利用函数作为第一类对象的特性获益。首先浏览一下对象中我们能使用的功能。JavaScript中对象有以下几种常用功能。
对象可通过字面量来创建{}。
对象可以赋值给变量、数组项,或其他对象的属性。
1var ninja = {}; ?--- 为变量赋值一个新对象
2ninjaArray.push({}); ?--- 向数组中增加一个新对象
3ninja.data = {}; ?--- 给某个对象的属性赋值为一个新对象
对象可以作为参数传递给函数。
1function hide(ninja){
2 ninja.visibility = false;
3}
4hide({}); ?--- 一个新创建的对象作为参数传递给函数
对象可以作为函数的返回值。
1function returnNewNinja() {
2 return {}; ?--- 从函数中返回了一个新对象
3}
对象能够具有动态创建和分配的属性。
1var ninja = {};
2ninja.name = "Hanzo"; ?--- 为对象分配一个新属性
其实,不同于很多其他编程语言,在JavaScript中,我们几乎能够用函数来实现同样的事。
1.1.1 函数是第一类对象
JavaScript中函数拥有对象的所有能力,也因此函数可被作为任意其他类型对象来对待。当我们说函数是第一类对象的时候,就是说函数也能够实现以下功能。
通过字面量创建。
1function ninjaFunction() {}
赋值给变量,数组项或其他对象的属性。
1var ninjaFunction = function() {}; ?--- 为变量赋值一个新函数
2ninjaArray.push(function(){}); ?--- 向数组中增加一个新函数
3ninja.data = function(){}; ?--- 给某个对象的属性赋值为一个新函数
作为函数的参数来传递。
1function call(ninjaFunction){
2 ninjaFunction();
3}
4call(function(){}); ?--- 一个新函数作为参数传递给函数
作为函数的返回值。
1function returnNewNinjaFunction() {
2 return function(){}; ?--- 返回一个新函数
3}
具有动态创建和分配的属性。
1var ninjaFunction = function(){};
2ninjaFunction.ninja = "Hanzo"; ?--- 为函数增加一个新属性
对象能做的任何一件事,函数也都能做。函数也是对象,唯一的特殊之处在于它是可调用的(invokable),即函数会被调用以便执行某项动作。
{JavaScript中的函数式编程!}
把函数作为第一类对象是函数式编程(functional programming)的第一步,函数式编程是一种编程风格,它通过书写函数式(而不是指定一系列执行步骤,就像那种更主流的命令式编程)代码来解决问题。函数式编程可以让代码更容易测试、扩展及模块化。不过这是一个很大的话题,因此本文仅对这个特性做了肯定。如果你对如何在JavacScript中利用函数式编程感兴趣,推荐阅读Luis Atencio著(由Manning出版社2016年出版)的《JavaScript函数式编程》,购买方式见www.manning.com/ books/functional-programming- in-JavaScript。
第一类对象的特点之一是,它能够作为参数传入函数。对于函数而言,这项特性也表明:如果我们将某个函数作为参数传入另一个函数,传入函数会在应用程序执行的未来某个时间点才执行。大家所知道的更一般的概念是回调函数(callback function)。下面我们来学习这个重要概念。
1.1.2 回调函数
每当我们建立了一个将在随后调用的函数时,无论是在事件处理阶段通过浏览器还是通过其他代码,我们都是在建立一个回调(callback)。这个术语源自于这样一个事实,即在执行过程中,我们建立的函数会被其他函数在稍后的某个合适时间点“再回来调用”。
有效运用JavaScript的关键在于回调函数,相信你已经在代码中使用了很多回调函数——不论是单击一次按钮、从服务端接收数据,还是UI动画的一部分。
本节我们会看一些实际使用回调函数的典型例子,例如处理事件、简单的排序集合。这部分内容会有点复杂,所以在深入学习之前,先透彻、完整地理解回调函数的概念,用最简单的形式来展现它。下面我们用一个简单例子来阐明这个概念,此例中的函数完全没什么实际用处,它的参数接收另一个函数的引用,并作为回调调用该函数:
1function useless(ninjaCallback) {
2 return ninjaCallback();
3}
这个函数可能没什么用,但它反映了函数的一种能力,即将函数作为另一个函数的参数,随后通过参数来调用该函数。
我们可以在清单1.1中测试一下这个名为useless的函数。
清单1.1 简单的回调函数例子
1var text = "Domo arigato!";
2report("Before defining functions");
3function useless(ninjaCallback) {
4 report("In useless function");
5 return ninjaCallback();
6} ?--- 函数定义,参数为一个回调函数,其函数体内会立即调用该回调函数
7function getText() {
8 report("In getText function");
9 return text;
10} ?--- 简单的函数定义,仅返回一个全局变量
11report("Before making all the calls");
12assert(useless(getText) === text,
13 "The useless function works! " + text); ?--- 把gerText作为回调函数传入上面的useless函数
14report("After the calls have been made");
在这个代码清单中,我们使用自定义函数report(在本文附录B中定义)来输出代码执行过程中的信息,这样一来我们就能通过这些信息来跟踪程序的执行过程。我们还使用了第1章中的断言函数assert。该函数通常使用两个参数。第一个参数是用于断言的表达式。本例中,我们需要确定使用参数getText调用useless函数返回的值与变量text是否相等(useless(getText) === text)。若第一个参数的执行结果为true,断言通过;反之,断言失败。第二个参数是与断言相关联的信息,通常会根据通过/失败来输出到日志上。(附录B中概括地探讨了测试,以及我们对assert函数和report函数的简单实现)。
这段代码执行完毕后,执行结果如图1.1所示。可以看到,使用getText参数调用useless回调函数后,得到了期望的返回值。
图1.1 清单1.1中代码的执行结果
我们还可以看看这个简单的回调函数具体是如何执行的。如图1.2所示,getText函数作为参数传入了useless函数。从该图中可以看到,在useless函数体内,通过callback参数可以取得getText函数的引用。随后,回调函数callback()的调用让getText函数得到执行,而我们作为参数传入的getText函数则通过useless函数被回调。
图1.2 执行useless(getText)调用后的执行流。getText作为参数传入useless函数并调用。useless函数体内对传入函数进行调用,本例中触发了getText函数的执行(即我们对getText函数进行回调)。
完成这个过程是很容易的,原因就在于JavaScript的函数式本质让我们能把函数作为第一类对象。更进一步说,我们的代码可以写成如下形式:
1<pre class="代码无行号"><code>var text = 'Domo arigato!';
2function useless(ninjaCallback) {
3 return ninjaCallback();
4}
5assert(useless(<strong>function () { return text;}</strong>) === text, ?--- 直接以参数形式定义回调函数
6 "The useless function works! " + text); </code></pre>
JavaScript的重要特征之一是可以在表达式出现的任意位置创建函数,除此之外这种方式能使代码更紧凑和易于理解(把函数定义放在函数使用处附近)。当一个函数不会在代码的多处位置被调用时,该特性可以避免用非必须的名字污染全局命名空间。
在回调函数的前述例子中,我们调用的是我们自己的回调。除此之外浏览器也会调用回调函数,回想一下第2章中的下述例子:
1document.body.addEventListener("mousemove", function() {
2
3 var second = document.getElementById("second")
4;
5 addMessage(second, "Event: mousemove"
6);
7});
上例同样是一个回调函数,作为mousemove事件的事件处理器,当事件发生时,会被浏览器调用。
{注意 }
本小节介绍的回调函数是其他代码会在随后的某个合适时间点“回过来调用”的函数。你已经学习了我们自己的代码调用回调(useless函数例子),也看到了当某事件发生时浏览器发起调用(mousemove例子)。注意这些很重要,不同于我们的例子,一些人认为回调会被异步调用,因此第一个例子不是一个真正的回调。这里之所以提到这些是以防万一你偶尔会遇见这类激烈的争论。
现在让我们看一个回调函数的用法,它能极大地简化集合的排序。
使用比较器排序
一般情况下只要我们拿到了一组数据集,就很可能需要对它进行排序。假如有一组随机序列的数字数组:0, 3, 2, 5, 7, 4, 8, 1。也许这个顺序没什么问题,但很可能早晚需要重新排列它。
通常来说,实现排序算法并不是编程任务中最微不足道的;我们需要为手中的工作选择最佳算法,实现它以适应当前的需要(使这些选项是按照特定顺序排列),并且需要小心仔细不能引入故障。除此之外,唯一特定于应用程序的任务是排列顺序。幸运的是,所有的JavaScript数组都能用sort方法。利用该方法可以只定义一个比较算法,比较算法用于指示按什么顺序排列。
这才是回调函数所要介入的!不同于让排序算法来决定哪个值在前哪个值在后,我们将会提供一个函数来执行比较。我们会让排序算法能够获取这个比较函数作为回调,使算法在其需要比较的时候,每次都能够调用回调。该回调函数的期望返回值为:如果传入值的顺序需要被调换,返回正数;不需要调换,返回负数;两个值相等,返回0。对于排序上述数组,我们对比较值做减法就能得到我们所需要的值。
1<pre class="代码无行号"><code>var values = [0, 3, 2, 5, 7, 4, 8, 1];
2values.sort<strong>(function(value1, value2) {</strong>
3 return value1 - value2;
4<strong>}</strong>);</code></pre>
没有必要思考排序算法的底层细节(甚至是选择了什么算法)。JavaScript引擎每次需要比较两个值的时候都会调用我们提供的回调函数。
函数式方式让我们能把函数作为一个单独实体来创建,正像我们对待其他类型一样,创建它、作为参数传入一个方法并将它作为一个参数来接收。函数就这样显示了它一等公民的地位。
1.2 函数作为对象的乐趣
本节我们会考察函数和其他对象类型的相似之处。也许让你感到惊讶的相似之处在于我们可以给函数添加属性:
1var ninja = {};
2ninja.name = "hitsuke"; ?--- 创建新对象并为其分配一个新属性
3var wieldSword = function(){};
4wieldSword.swordType = "katana"; ?--- 创建新函数并为其分配一个新属性
我们再来看看这种特性所能做的更有趣的事:
在集合中存储函数使我们轻易管理相关联的函数。例如,某些特定情况下必须调用的回调函数。
记忆让函数能记住上次计算得到的值,从而提高后续调用的性能。
让我们行动起来吧。
1.2.1 存储函数
某些例子中(例如,我们需要管理某个事件发生后需要调用的回调函数集合),我们会存储元素唯一的函数集合。当我们向这样的集合中添加函数时,会面临两个问题:哪个函数对于这个集合来说是一个新函数,从而需要被加入到该集合中?又是哪个函数已经存在于集合中,从而不需要再次加入到集合中?一般来说,管理回调函数集合时,我们并不希望存在重复函数,否则一个事件会导致同一个回调函数被多次调用。
一种显著有效的简单方法是把所有函数存入一个数组,通过循环该数组来检查重复函数。令人遗憾的是,这种方法的性能较差,尤其作为一个“忍者”要把事情干得漂亮而不仅是做到能用。我们可以使用函数的属性,用适当的复杂度来实现它,如清单1.2所示。
清单1.2 存储唯一函数集合
1var store = {
2 nextId: 1, ?--- 跟踪下一个要被复制的函数
3 cache: {}, ?--- 使用一个对象作为缓存,我们可以在其中存储函数
4 add: function(fn) {
5 if (!fn.id) {
6 fn.id = this.nextId++;
7 this.cache[fn.id] = fn;
8 return true;
9 }
10 } ?--- 仅当函数唯一时,将该函数加入缓存
11};
12function ninja(){}
13assert(store.add(ninja),
14 "Function was safely added.");
15assert(!store.add(ninja),
16 "But it was only added once."); ?--- 测试上面代码按预期工作
在这个清单中,我们创建了一个对象赋值给变量store,这个变量中存储的是唯一的函数集合。这个对象有两个数据属性:其一是下一个可用的id,另外一个缓存着已经保存的函数。函数通过add()方法添加到缓存中。
1add: function(fn) {
2 if (!fn.id) {
3 fn.id = this.nextId++;
4 this.cache[fn.id] = fn;
5 return true;
6 }
7...
在add函数内,我们首先检查该函数是否已经存在id属性。如果当前的函数已经有id属性,我们则假设该函数已经被处理过了,从而忽略该函数,否则为该函数分配一个id(同时增加nextId)属性,并将该函数作为一个属性增加到cache上,id作为属性名。紧接着该函数的返回值为true,从而可得知调用了add()后,函数是什么时候被添加到存储中的。
在浏览器中运行该程序后,页面显示:测试程序尝试两次添加ninja()函数,而该函数只被添加一次到存储中,如图1.3所示。第9章展示了用于操作合集的更好技术,它利用了ES6的新的对象类型集合(Set)。
图1.3 给函数附加一个属性后,我们就能够引用该属性。本例通过这种方式可以确保该ninja函数仅被添加到函数中一次
另外一种有用的技巧是当使用函数属性时,可以通过该属性修改函数自身。这个技术可以用于记忆前一个计算得到的值,为之后计算节省时间。
1.2.2 自记忆函数
如同前面所提到的,记忆化(memoization)是一种构建函数的处理过程,能够记住上次计算结果。在这个果壳里,当函数计算得到结果时就将该结果按照参数存储起来。采用这种方式时,如果另外一个调用也使用相同的参数,我们则可以直接返回上次存储的结果而不是再计算一遍。像这样避免既重复又复杂的计算可以显著地提高性能。对于动画中的计算、搜索不经常变化的数据或任何耗时的数学计算来说,记忆化这种方式是十分有用的。
看看下面的这个例子,它使用了一个简单的(也的确是效率不高的)算法来计算素数。尽管这是一个复杂计算的简单例子,但它经常被应用到大计算量的场景中(例如可以引申到通过字符串生成MD5算法),这里不便展示。
从外表来说,这个函数和任何普通函数一样,但在内部我们会构建一个结果缓存,它会保存函数每次计算得到的结果,如清单1.3所示。
清单1.3 计算先前得到的值
1function isPrime(value) {
2 if (!isPrime.answers) {
3 isPrime.answers = {};
4 } ?--- 创建缓存
5 if (isPrime.answers[value] !== undefined) {
6 return isPrime.answers[value];
7 } ?--- 检查缓存的值
8 var prime = value !== 0 && value !== 1; // 1 is not a prime
9 for (var i = 2; i < value; i++) {
10 if (value % i === 0) {
11 prime = false;
12 break;
13 }
14 }
15 return isPrime.answers[value] = prime; ?--- 存储计算的值
16}
17assert(isPrime(5), "5 is prime!");
18assert(isPrime.answers[5], "The answer was cached!"); ?--- 测试该函数是否正常工作
在isPrime函数中,首先通过检查它的answers属性来确认是否已经创建了一个缓存,如果没有创建,则新建一个:
1if (!isPrime.answers) {
2 isPrime.answers = {};
3}
只有第一次函数调用才会创建这个初始空对象,之后这个缓存就已经存在了。然后我们会检查参数中传的值是否已经存储到缓存中:
1if (isPrime.answers[value] !== undefined) {
2 return isPrime.answers[value];
3}
这个缓存会针对参数中的值value来存储该值是否为素数(true或false)。如果我们在缓存中找到该值,函数会直接返回。
1return isPrime.answers[value] = prime;
这个缓存是函数自身的一个属性,所以只要该函数还存在,缓存也就存在。
最后的测试结果可以看到记忆函数生效了。
1assert(isPrime(5), "5 is prime!");
2assert(isPrime.answers[5], "The answer was cached!");
这个方法具有两个优点。
由于函数调用时会寻找之前调用所得到的值,所以用户最终会乐于看到所获得的性能收益。
它几乎是无缝地发生在后台,最终用户和页面作者都不需要执行任何特殊请求,也不需要做任何额外初始化,就能顺利进行工作。
当然这种方法并不是像玫瑰和提琴一样完美,还是要权衡利弊。
任何类型的缓存都必然会为性能牺牲内存。
纯粹主义者会认为缓存逻辑不应该和业务逻辑混合,函数或方法只需要把一件事做好。但不必担心,在第8章你会了解到如何解决这类问题。
对于这类问题很难做负载测试或估计算法复杂度,因为结果依赖于函数之前的输入。
现在你看到了函数作为第一类公民的一些实例,接下来看看不同的函数定义的方式。
1.3 函数定义
JavaScript函数通常由函数字面量(function literal)来创建函数值,就像数字字面量创建一个数字值一样。要记住这一点,作为第一类对象,函数是可以用在编程语言中的值,就像例句字符串或数字的值。无论你是否意识到了这一点,你一直都是这样做的。
JavaScript提供了几种定义函数的方式,可以分为4类。
函数定义(function declarations)和函数表达式(function expressions)——最常用,在定义函数上却有微妙不同的的两种方式。人们通常不会独立地看待它们,但正如你将看到的,意识到两者的不同能帮我们理解函数何时能够被调用。
1function myFun(){ return 1;}
箭头函数(通常被叫做lambda函数)——ES6新增的JavaScript标准,能让我们以尽量简洁的语法定义函数。
1myArg => myArg*2
函数构造函数—— 一种不常使用的函数定义方式,能让我们以字符串形式动态构造一个函数,这样得到的函数是动态生成的。这个例子动态地创建了一个函数,其参数为a和b,返回值为两个数的和。
1new Function('a', 'b', 'return a + b')
生成器函数——ES6新增功能,能让我们创建不同于普通函数的函数,在应用程序执行过程中,这种函数能够退出再重新进入,在这些再进入之间保留函数内变量的值。我们可以定义生成器版本的函数声明、函数表达式、函数构造函数。
1function* myGen(){ yield 1; }
理解这几种方式的不同很重要,因为函数创建的方式很大程度地影响了函数可被调用的时间、函数的行为以及函数可以在哪个对象上被调用。
这一节中,我们将会探索函数定义、函数表达式和箭头函数。你将学到它们的语法和它们的工作方式,我们也将会在本文中多次回顾它们的细节。另一方面,生成器函数则有一点独特,它不同于普通函数。在第6章我们会再来学习它们的细节。
剩下的JavaScript特性——函数构造函数我们将全部跳过。尽管它具有某些有趣的应用场景,尤其是在动态创建和执行代码时,但我们依然认为它是JavaScript语言的边缘功能。如果你想知道更多关于函数构造函数的信息,请访问http://mng.bz/ZN8e。
让我们先用最简单、最传统的方式定义函数吧:函数声明和函数表达式。
1.3.1 函数声明和函数表达式
JavaScript中定义函数最常用的方式是函数声明和函数表达式。这两种技术非常相似,有时甚至难以区分,但在后续章节中你将看到,它们之间还是存在着微妙的差别。
函数声明
JavaScript定义函数的最基本方式是函数声明(见图1.4)。正如你所见,每个函数声明以强制性的function开头,其后紧接着强制性的函数名,以及括号和括号内一列以逗号分隔的可选参数名。函数体是一列可以为空的表达式,这些表达式必须包含在花括号内。除了这种形式以外,每个函数声明还必须包含一个条件:作为一个单独的JavaScript语句,函数声明必须独立(但也能够被包含在其他函数或代码块中,在下一小节中你将会准确理解其含义)。
图1.4 函数声明是独立的,是独立的JavaScript代码块(它可以被包含在其他函数中)
清单1.4展示了两条函数声明例子。
清单1.4 函数声明示例
1function samurai() {
2 return "samurai here"; ?--- 在全局代码中定义samurai函数
3}
4function ninja() { ?--- 在全局代码中定义ninja函数
5 function hiddenNinja() {
6 return "ninja here";
7 } ?--- 在ninja函数内定义hiddenNinja函数
8 return hiddenNinja();
9}
如果你对函数式语言没有太多了解,仔细看一看,你可能会发现你并不习惯这种使用方式: 一个函数被定义在另一个函数之中!
1function ninja() {
2 function hiddenNinja() {
3 return "ninja here";
4 }
5 return hiddenNinja();
6}
在JavaScript中,这是一种非常通用的使用方式,这里用它作为例子是为了再次强调JavaScript中函数的重要性。
{注意 }
让函数包含在另一个函数中可能会因为忽略作用域的标识符解析而引发一些有趣的问题,但现在可以先留下这个问题,第5章会重新回顾这个问题的细节。
函数表达式
正如我们多次所提到的,JavaScript中的函数是第一类对象,除此以外也就意味着它们可以通过字面量创建,可以赋值给变量和属性,可以作为传递给其他函数的参数或函数的返回值。正因为函数有如此的基础结构,所以JavaScript能让我们把函数和其他表达式同等看待。例如,如下例子中我们可以使用数字字面量:
1var a = 3;
2myFunction(4);
同样,在相同位置可以用函数字面量:
1var a = function() {};
2myFunction(function(){});
这种总是其他表达式的一部分的函数(作为赋值表达式的右值,或者作为其他函数的参数)叫作函数表达式。函数表达式非常重要,在于它能准确地在我们需要使用的地方定义函数,这个过程能让代码易于理解。清单1.5展示了函数声明和函数表达式的不同之处。
清单1.5 函数声明和函数表达式
1<pre class="代码无行号"><code>function myFunctionDeclaration(){ ?--- 独立的函数声明
2 function innerFunction() {} ?--- 内部函数声明
3}
4var myFunc = function(){}; ?--- 函数表达式作为变量声明赋值语句中的一部分
5myFunc(function(){ ?--- 函数表达式作为一次函数调用中的参数
6 return function(){}; ?--- 函数表达式作为函数返回值
7});
8(function <strong>namedFunctionExpression</strong> () {
9})(); ?--- 作为函数调用的一部分,命名函数表达式会被立即调用
10+function(){}();
11-function(){}();
12!function(){}();
13~function(){}(); ?--- 函数有达式可以作为一元操作符的参数立即调用</code></pre>
示例代码的开头是标准函数声明,其包含一个内部函数声明:
1function myFunctionDeclaration(){
2 function innerFunction() {}
3}
从这个示例中你能够看到,函数声明是如何作为JavaScript代码中的独立表达式的,但它也能够包含在其他函数体内。与之比较的是函数表达式,它通常作为其他语句的一部分。它们被放在表达式级别,作为变量声明(或者赋值)的右值:
1var myFunc = function(){};
或者作为另一个函数调用的参数或返回值。
1myFunc(function() {
2 return function(){};
3});
函数声明和函数表达式除了在代码中的位置不同以外,还有一个更重要的不同点是:对于函数声明来说,函数名是强制性的,而对于函数表达式来说,函数名则完全是可选的。
函数声明必须具有函数名是因为它们是独立语句。一个函数的基本要求是它应该能够被调用,所以它必须具有一种被引用方式,于是唯一的方式就是通过它的名字。
从另一方面来看,函数表达式也是其他JavaScript表达式的一部分,所以我们也就具有了调用它们的替代方案。例如,如果一个函数表达式被赋值给了一个变量,我们可以用该变量来调用函数。
1var doNothing = function(){};
2doNothing();
或者,如果它是另外一个函数的参数,我们可以在该函数中通过相应的参数名来调用它。
1function doSomething(action) {
2 action();
3}
立即函数
函数表达式可以放在初看起来有些奇怪的位置上,例如通常认为是函数标识符的位置。接下来仔细看看这个构造(如图1.5所示)。
图1.5 标准函数的调用和函数表达式的立即调用的对比
当想进行函数调用时,我们需要使用能够求值得到函数的表达式,其后跟着一对函数调用括号,括号内包含参数。在最基本的函数调用中,我们把求值得到函数的标识符作为左值(如图1.5所示)。不过用于被括号调用的表达式不必只是一个简单的标识符,它可以是任何能够求值得到函数的表达式。例如,指定一个求值得到函数的表达式的最简单方式是使用函数表达式。如图1.5中右图所示,我们首先创建了一个函数,然后立即调用这个新创建的函数。这种函数叫作立即调用函数表达式(IIFE),或者简写为立即函数。这一特性能够模拟JavaScript中的模块化,故可以说它是JavaScript开发中的重要理念。第11章中会集中讨论IIFE的应用。
{加括号的函数表达式!}
还有一件可能困扰你的是上面例子中我们立即调用的函数表达式方式:函数表达式被包裹在一对括号内。为什么这样做呢?其原因是纯语法层面的。JavaScript解析器必须能够轻易区分函数声明和函数表达式之间的区别。如果去掉包裹函数表达式的括号,把立即调用作为一个独立语句function() {}(3),JavaScript开始解析时便会结束,因为这个独立语句以function开头,那么解析器就会认为它在处理一个函数声明。每个函数声明必须有一个名字(然而这里并没有指定名字),所以程序执行到这里会报错。为了避免错误,函数表达式要放在括号内,为JavaScript解析器指明它正在处理一个函数表达式而不是语句。
还有一种相对简单的替代方案(function(){}(3))也能达到相同目标(然而这种方案有些奇怪,故不常使用)。把立即函数的定义和调用都放在括号内,同样可以为JavaScript解析器指明它正在处理函数表达式。
表1.5中最后4个表达式都是立即调用函数表达式主题的4个不同版本,在JavaScript库中会经常见到这几种形式:
1+function(){}();
2-function(){}();
3!function(){}();
4~function(){}();
不同于用加括号的方式区分函数表达式和函数声明,这里我们使用一元操作符+、-、!和~。这种做法也是用于向JavaScript引擎指明它处理的是表达式,而不是语句。从计算机的角度来讲,注意应用一元操作符得到的结果没有存储到任何地方并不重要,只有调用IIFE才重要。现在我们已经学会了JavaScript中两种基本的函数定义方式(函数声明和函数表达式)的细节。接下来开始探索JavaScript标准中的新增特性:箭头函数。
1.3.2 箭头函数
注意:
箭头函数是JavaScript标准中的ES6新增项(浏览器兼容性可参考http://mng.bz/8bnH)。
由于JavaScript中会使用大量函数,增加简化创建函数方式的语法十分有意义,也能让我们的开发者生活更愉快。在很多方式中,箭头函数是函数表达式的简化版。一起来回顾一下本文开始的排序例子。
1var values = [0, 3, 2, 5, 7, 4, 8, 1];
2values.sort(function(value1,value2){
3 return value1 – value2;
4});
这个例子中,数组对象的排序方法的参数传入了一个回调函数表达式,JavaScript引擎会调用这个回调函数以降序排序数组。现在来看看如何用箭头函数来做完全相同的工作:
1var values = [0, 3, 2, 5, 7, 4, 8, 1];
2
3values.sort((
4
5value1,value2) => value1 – value2
6
7);
看到这是多么简洁了吧?
这种写法不会产生任何因为书写function关键字、大括号或者return语句导致的混乱。箭头函数语句有着比函数表达式更为简单的方式:函数传入两个参数并返回其差值。注意这个新操作符——胖箭头符号=>(等号后面跟着大于号)是定义箭头函数的核心。
现在来解析箭头函数的语法,首先看看它的最简形式:
1param => expression
这个箭头函数接收一个参数并返回表达式的值,如下面的清单1.6就使用了这种语法。
清单1.6 比较箭头函数和函数表达式
1var greet = name => "Greetings " + name; ?--- 定义箭头函数
2
3assert(greet("Oishi") === "Greetings Oishi", "Oishi is properly greeted")
4;
5
6var anotherGreet = function(nam
7e){
8 return "Greetings " + n
9ame;
10}; ?--- 定义
11函数表达式
12assert(anotherGreet("Oishi") === "Greetings O
13ishi",
14 "Again, Oishi is properly greeted");
稍作欣赏,使用箭头函数的代码即简洁又清楚。这是箭头函数的最简语法,但一般情况下,箭头函数会被定义成两种方式,如图1.6所示。
稍作欣赏,使用箭头函数的代码即简洁又清楚。这是箭头函数的最简语法,但一般情况下,箭头函数会被定义成两种方式,如图1.6所示。
图1.6 箭头函数的语法
如你所见,箭头函数的定义以一串可选参数名列表开头,参数名以逗号分隔。如果没有参数或者多余一个参数时,参数列表就必须包裹在括号内。但如果只有一个参数时,括号就不是必须的。参数列表之后必须跟着一个胖箭头符号,以此向我们和JavaScript引擎指示当前处理的是箭头函数。
胖箭头操作符后面有两种可选方式。如果要创建一个简单函数,那么可以把表达式放在这里(可以是数学运算、其他的函数调用等),则该函数的返回值即为此表达式的返回值。例如,第一个箭头函数的示例如下:
1var greet = name => "Greetings " + name;
这个箭头函数的返回值是字符串“Greetings”和参数name的结合。在其他案例中,当箭头函数没那么简单从而需要更多代码时,箭头操作符后则可以跟一个代码块,例如:
1var greet = name => {
2 var helloString = 'Greetings ';
3 return helloString + name;
4};
这段代码中箭头函数的返回值和普通函数一样。如果没有return语句,返回值是undefined;反之,返回值就是return表达式的值。
在本文中我们会多次回顾箭头函数。除此之外,我们还会展示箭头函数的一些额外功能,它能帮助我们规避一些在很多标准函数中可能遇到的难以捉摸的缺陷。箭头函数和很多其他函数一样,可以通过接收参数来执行任务。接下来看看当向函数内传入参数后,该参数值发生了什么。
本文摘自《JavaScript忍者秘籍(第2版)》
《JavaScript忍者秘籍 第2版》
[美] John,Resig(莱西格),Bear,Bibeault(贝比奥特),Josip ... 著
点击封面购买纸书
JavaScript 正以惊人的速度成为各种应用程序的通用语言,包括 Web、桌面、云和移动设备上的应用程序。当成为 JavaScript 专业开发者时,你将拥有可应用于所有这些领域的、强大的技能集。
《JavaScript 忍者秘籍(第2版)》使用实际的案例清晰地诠释每一个核心概念和技术。本书向读者介绍了如何掌握 JavaScript 核心的概念,诸如函数、闭包、对象、原型和 promise,同时还介绍了 JavaScript API, 包括 DOM、事件和计时器。你将学会测试、跨浏览器开发,所有这些都是高级JavaScript开发者应该掌握的技能。
小福利
关注【异步社区】服务号,转发本文至朋友圈或 50 人以上微信群,截图发送至异步社区服务号后台,并在文章底下留言,分享你的JavaScript开发经验或者本书的试读体验,我们将选出3名读者赠送《JavaScript 忍者秘籍(第2版)》1本,赶快积极参与吧!
活动截止时间:2018 年3月15日
在“异步社区”后台回复“关注”,即可免费获得2000门在线视频课程;推荐朋友关注根据提示获取赠书链接,免费得异步图书一本。赶紧来参加哦!
扫一扫上方二维码,回复“关注”参与活动!
阅读原文
*请认真填写需求信息,我们会在24小时内与您取得联系。