高阶函数

高阶函数是指那些可以接受函数作为参数或者返回函数作为结果的函数。这种特性使得JavaScript的函数变得异常灵活和强大,为开发者提供了丰富的工具和手段来编写更加简洁、可重用和模块化的代码。 本文将详细介绍JavaScript高阶函数的概念、特性、常见用法以及在实际开发中的应用。 一、高阶函数的概念 高阶函数是函数式编程中的一个核心概念。在JavaScript中,函数是一等公民,这意味着函数可以像其他数据类型(如数字、字符串、对象等)一样被传递和操作。高阶函数正是利用了这一特性,使得函数可以作为参数传递给其他函数,或者作为其他函数的返回值。 二、高阶函数的特性 函数作为参数:高阶函数可以接受一个或多个函数作为参数。这使得我们可以将特定的行为(即函数)作为参数传递给高阶函数,从而实现更加灵活和可重用的代码。 函数作为返回值:高阶函数可以返回一个函数作为结果。这种特性允许我们创建可以动态生成新函数的函数,这些新函数可以根据需要执行不同的行为。 闭包:闭包是高阶函数的一个重要特性。闭包允许函数访问其外部作用域中的变量,即使该函数在其外部作用域之外执行。这使得高阶函数可以捕获和存储状态,从而创建更加复杂和强大的函数行为。 三、高阶函数的常见用法 数组方法:JavaScript中的数组方法(如map、filter、reduce等)都是高阶函数的典型例子。这些方法接受一个函数作为参数,并对数组中的每个元素执行该函数,从而返回一个新的数组或单一结果。 map方法:用于创建一个新数组,其结果是该数组中的每个元素调用一个提供的函数后返回的结果。 filter方法:创建一个新数组,其包含通过所提供函数实现的测试的所有元素。 reduce方法:对数组中的每个元素执行一个由您提供的reducer函数(升序执行),将其结果汇总为单个返回值。 函数组合:函数组合是指将多个函数组合在一起形成一个新的函数。通过高阶函数,我们可以轻松地将多个函数组合在一起,从而创建一个更加复杂和强大的函数。这种技术在实际开发中非常有用,特别是当我们需要处理一系列转换或操作时。 柯里化:柯里化是一种将接收多个参数的函数转换为一系列接收单一参数的函数的技术。虽然柯里化本身不是高阶函数,但高阶函数在实现柯里化时扮演着重要角色。通过高阶函数,我们可以创建一个接受原始函数作为参数的柯里化函数,并返回一个新的函数来处理单一参数。 创建高阶函数:我们可以编写自己的高阶函数来实现特定的行为。例如,我们可以编写一个高阶函数来接受一个函数和一个数组作为参数,并返回该数组中应用该函数后的结果。 四、高阶函数在实际开发中的应用 1. 数据转换 高阶函数非常适合用于数据的转换和映射。例如,我们可以使用Array.prototype.map方法将一个数组中的每个元素转换为另一种形式。 // 定义一个高阶函数,它接受一个转换函数和一个数组作为参数 const transformArray = (transformFn, array) => array.map(transformFn); // 定义一个简单的转换函数,将数字转换为它的平方 const square = x => x * x; // 使用高阶函数和转换函数来处理数组 const numbers = [1, 2, 3, 4, 5]; const squaredNumbers = transformArray(square, numbers); console.log(squaredNumbers); // 输出: [1, 4, 9, 16, 25] 在这个例子中,transformArray是一个高阶函数,它接受一个转换函数transformFn和一个数组array作为参数。然后,它使用Array.prototype.map方法来遍历数组,并对每个元素应用转换函数。 2. 事件处理 在Web开发中,事件处理是一个常见的任务。通过高阶函数,我们可以创建更加灵活和可重用的事件处理器。 // 定义一个高阶函数,它接受一个事件处理函数和一个事件类型作为参数 const createEventHandler = (handlerFn, eventType) => event => { if (event.type === eventType) { handlerFn(event); } }; // 定义一个简单的事件处理函数,用于打印事件信息 const logEvent = event => console.log(`Event type: ${event.type}, Target: ${event.target}`); // 创建一个点击事件处理器 const handleClick = createEventHandler(logEvent, 'click'); // 假设我们有一个DOM元素,我们可以为它添加事件监听器 // const button = document.querySelector('button'); // button.addEventListener('click', handleClick); // 为了演示,我们直接调用处理器并传递一个模拟的事件对象 const mockEvent = { type: 'click', target: document.body }; handleClick(mockEvent); // 输出: Event type: click, Target: [object HTMLBodyElement] 在这个例子中,createEventHandler是一个高阶函数,它接受一个事件处理函数handlerFn和一个事件类型eventType作为参数,并返回一个新的函数。这个新的函数会检查传入的事件对象的类型是否与指定的事件类型匹配,如果匹配,则调用事件处理函数。 ...

October 1, 2024

原型链与继承

JavaScript 中没有类的概念的,主要通过原型链来实现继承。通常情况下,继承意味着复制操作,然而 JavaScript 默认并不会复制对象的属性,相反,JavaScript 只是在两个对象之间创建一个关联(原型对象指针),这样,一个对象就可以通过委托访问另一个对象的属性和函数,所以与其叫继承,委托的说法反而更准确些。 原型 当我们 new 了一个新的对象实例,明明什么都没有做,就直接可以访问 toString 、valueOf 等原生方法。那么这些方法是从哪里来的呢?答案就是原型。 在控制台打印一个空对象时,我们可以看到,有很多方法,已经“初始化”挂载在内置的 __proto__ 对象上了。这个内置的 __proto__ 是一个指向原型对象的指针,它会在创建一个新的引用类型对象时(显示或者隐式)自动创建,并挂载到新实例上。当我们尝试访问实例对象上的某一属性 / 方法时,如果实例对象上有该属性 / 方法时,就返回实例属性 / 方法,如果没有,就去 __proto__ 指向的原型对象上查找对应的属性 / 方法。这就是为什么我们尝试访问空对象的 toString 和 valueOf 等方法依旧能访问到的原因,JavaScript 正式以这种方式为基础来实现继承的。 构造函数 如果说实例的 __proto__ 只是一个指向原型对象的指针,那就说明在此之前原型对象就已经创建了,那么原型对象是什么时候被创建的呢?这就要引入构造函数的概念。 其实构造函数也就只是一个普通的函数而已,如果这个函数可以使用 new 关键字来创建它的实例对象,那么我们就把这种函数称为 构造函数。 // 普通函数 function person () {} // 构造函数,函数首字母通常大写 function Person () {} const person = new Person(); 原型对象正是在构造函数被声明时一同创建的。构造函数被申明时,原型对象也一同完成创建,然后挂载到构造函数的 prototype 属性上: 原型对象被创建时,会自动生成一个 constructor 属性,指向创建它的构造函数。这样它俩的关系就被紧密地关联起来了。 细心的话,你可能会发现,原型对象也有自己的 __proto__ ,这也不奇怪,毕竟万物皆对象嘛。原型对象的 proto 指向的是 Object.prototype。那么 Object.prototype.__proto__ 存不存在呢?其实是不存在的,打印的话会发现是 null 。这也证明了 Object 是 JavaScript 中数据类型的起源。 ...

October 1, 2024

函数柯里化

柯里化是函数的一个比较高级的应用,想要理解它并不简单。 通过《函数与函数式编程》的学习,我们知道,接收函数作为参数的函数,都可以叫做高阶函数。我们常常利用高阶函数来封装一些公共的逻辑。 我们接下来要学习的柯里化,其实就是高阶函数的一种特殊用法。 柯里化是指这样一个函数(假设叫做createCurry),他接收函数A作为参数,运行后能够返回一个新的函数。并且这个新的函数能够处理函数A的剩余参数。 这样的定义可能不太好理解,我们可以通过下面的例子配合理解。 假如有一个接收三个参数的函数A。 function A(a, b, c) { // do something } 又假如我们有一个已经封装好了的柯里化通用函数createCurry。他接收bar作为参数,能够将A转化为柯里化函数,返回结果就是这个被转化之后的函数。 var _A = createCurry(A); 那么_A作为createCurry运行的返回函数,他能够处理A的剩余参数。因此下面的运行结果都是等价的。 _A(1, 2, 3); _A(1, 2)(3); _A(1)(2, 3); _A(1)(2)(3); A(1, 2, 3); 函数A被createCurry转化之后得到柯里化函数_A,_A能够处理A的所有剩余参数。因此柯里化也被称为部分求值。 在简单的场景下,我们可以不用借助柯里化通用式来转化得到柯里化函数,我们可以凭借眼力自己封装。 例如有一个简单的加法函数,他能够将自身的三个参数加起来并返回计算结果。 function add(a, b, c) { return a + b + c; } 那么add函数的柯里化函数_add则可以如下: function _add(a) { return function(b) { return function(c) { return a + b + c; } } } 因此下面的运算方式是等价的。 add(1, 2, 3); _add(1)(2)(3); 当然,柯里化通用式具备更加强大的能力,我们靠眼力自己封装的柯里化函数则自由度偏低。因此我们仍然需要知道自己如何去封装这样一个柯里化的通用式。 首先通过_add可以看出,柯里化函数的运行过程其实是一个参数的收集过程,我们将每一次传入的参数收集起来,并在最里层里面处理。因此我们在实现createCurry时,可以借助这个思路来进行封装。 封装如下: // 简单实现,参数只能从右到左传递 function createCurry(func, args) { var arity = func.length; var args = args || []; return function() { var _args = [].slice.call(arguments); [].push.apply(_args, args); // 如果参数个数小于最初的func.length,则递归调用,继续收集参数 if (_args.length < arity) { return createCurry.call(this, func, _args); } // 参数收集完毕,则执行func return func.apply(this, _args); } } 尽管我已经做了足够详细的注解,但是我想理解起来也并不是那么容易,因此建议大家用点耐心多阅读几遍。这个createCurry函数的封装借助闭包与递归,实现了一个参数收集,并在收集完毕之后执行所有参数的一个过程。 ...

October 1, 2024

函数与函数式编程

纵观JavaScript中所有必须需要掌握的重点知识中,函数是我们在初学的时候最容易忽视的一个知识点。在学习的过程中,可能会有很多人、很多文章告诉你面向对象很重要,原型很重要,可是却很少有人告诉你,面向对象中所有的重点难点,几乎都与函数息息相关。 包括之前几篇文章介绍的执行上下文,变量对象,闭包,this等,都是围绕函数的细节来展开。 很多人在学习中,很急切的希望自己快一点开始学习面向对象,学习模块,学习流行框架,然后迅速成为高手。但是我可以很负责的告诉你,关于函数的这些基础东西没理解到一定程度,那么你的学习进展一定是举步维艰的。 所以,大家一定要重视函数! 当然,关于函数的重点,难点在前面几篇文章都已经说得差不多了,这篇文章主要总结一下函数的基础知识,并初步学习函数式编程的思维。 一、函数声明、函数表达式、匿名函数与自执行函数 关于函数在实际开发中的应用,大体可以总结为函数声明、函数表达式、匿名函数、自执行函数。 函数声明 我们知道,JavaScript中,有两种声明方式,一个是使用var的变量声明,另一个是使用function的函数声明。 在前端基础进阶(三):变量对象详解中我有提到过,变量对象的创建过程中,函数声明比变量声明具有更为优先的执行顺序,即我们常常提到的函数声明提前。因此我们在执行上下文中,无论在什么位置声明了函数,我们都可以在同一个执行上下文中直接使用该函数。 fn(); // function function fn() { console.log('function'); } 函数表达式 与函数声明不同,函数表达式使用了var进行声明,那么我们在确认他是否可以正确使用的时候就必须依照var的规则进行判断,即变量声明。我们知道使用var进行变量声明,其实是进行了两步操作。 // 变量声明 var a = 20; // 实际执行顺序 var a = undefined; // 变量声明,初始值undefined,变量提升,提升顺序次于function声明 a = 20; // 变量赋值,该操作不会提升 同样的道理,当我们使用变量声明的方式来声明函数时,就是我们常常说的函数表达式。函数表达的提升方式与变量声明一致。 fn(); // 报错 var fn = function() { console.log('function'); } 上例子的执行顺序为: var fn = undefined; // 变量声明提升 fn(); // 执行报错 fn = function() { // 赋值操作,此时将后边函数的引用赋值给fn console.log('function'); } 因此,由于声明方式的不同,导致了函数声明与函数表达式在使用上的一些差异需要我们注意,除此之外,这两种形式的函数在使用上并无不同。 关于上面例子中,函数表达式中的赋值操作,在其他一些地方也会被经常使用,我们清楚其中的关系即可。 在构造函数中添加方法 function Person(name) { this.name = name; this.age = age; // 在构造函数内部中添加方法 this.getAge = function() { return this.age; } this. } // 给原型添加方法 Person.prototype.getName = function() { return this.name; } // 在对象中添加方法 var a = { m: 20, getM: function() { return this.m; } } 匿名函数 ...

October 1, 2024

作用域链与闭包

初学JavaScript的时候,我在学习闭包上,走了很多弯路。而这次重新回过头来对基础知识进行梳理,要讲清楚闭包,也是一个非常大的挑战。 闭包有多重要?如果你是初入前端的朋友,我没有办法直观的告诉你闭包在实际开发中的无处不在,但是我可以告诉你,前端面试,必问闭包。面试官们常常用对闭包的了解程度来判定面试者的基础水平,保守估计,10个前端面试者,至少5个都死在闭包上。 可是为什么,闭包如此重要,还是有那么多人没有搞清楚呢?是因为大家不愿意学习吗?还真不是,而是我们通过搜索找到的大部分讲解闭包的中文文章,都没有清晰明了的把闭包讲解清楚。要么浅尝辄止,要么高深莫测,要么干脆就直接乱说一通。包括我自己曾经也写过一篇关于闭包的总结,回头一看,不忍直视[捂脸]。 因此本文的目的就在于,能够清晰明了得把闭包说清楚,让读者朋友们看了之后,就把闭包给彻底学会了,而不是似懂非懂。 一、作用域与作用域链 在详细讲解作用域链之前,我默认你已经大概明白了JavaScript中的下面这些重要概念。这些概念将会非常有帮助。 基础数据类型与引用数据类型 内存空间 垃圾回收机制 执行上下文 变量对象与活动对象 如果你暂时还没有明白,可以去看本系列的前三篇文章,本文文末有目录链接。为了讲解闭包,已经为大家做好了基础知识的铺垫哦。 作用域 在JavaScript中,我们可以将作用域定义为一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。 这里的标识符,指的是变量名或者函数名 JavaScript中只有全局作用域与函数作用域(因为eval我们平时开发中几乎不会用到它,这里不讨论)。 作用域与执行上下文是完全不同的两个概念。我知道很多人会混淆他们,但是一定要仔细区分。 JavaScript代码的整个执行过程,分为两个阶段,代码编译阶段与代码执行阶段。编译阶段由编译器完成,将代码翻译成可执行代码,这个阶段作用域规则会确定。执行阶段由引擎完成,主要任务是执行可执行代码,执行上下文在这个阶段创建。 作用域链 回顾一下,我们分析过的执行上下文的生命周期,如下图: 我们知道函数在调用激活时,会开始创建对应的执行上下文,在执行上下文生成的过程中,变量对象,作用域链,以及this的值会分别被确定。之前一篇文章我们详细说明了变量对象,而这里,我们将详细说明作用域链。 作用域链,是由当前环境与上层环境的一系列变量对象组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。 为了帮助大家理解作用域链,我我们先结合一个例子,以及相应的图示来说明。 var a = 20; function test() { var b = a + 10; function innerTest() { var c = 10; return b + c; } return innerTest(); } test(); 在上面的例子中,全局,函数test,函数innerTest的执行上下文先后创建。我们设定他们的变量对象分别为VO(global),VO(test), VO(innerTest)。而innerTest的作用域链,则同时包含了这三个变量对象,所以innerTest的执行上下文可如下表示。 innerTestEC = { VO: {...}, // 变量对象 scopeChain: [VO(innerTest), VO(test), VO(global)], // 作用域链 } 我们可以直接用一个数组来表示作用域链,数组的第一项scopeChain[0]为作用域链的最前端,而数组的最后一项,为作用域链的最末端,所有的最末端都为全局变量对象。 很多人会误解为当前作用域与上层作用域为包含关系,但其实并不是。以最前端为起点,最末端为终点的单方向通道我认为是更加贴切的形容。如图。 注意,因为变量对象在执行上下文进入执行阶段时,就变成了活动对象,这一点在上一篇文章中已经讲过,因此图中使用了AO来表示。Active Object 是的,作用域链是由一系列变量对象组成,我们可以在这个单向通道中,查询变量对象中的标识符,这样就可以访问到上一层作用域中的变量了。 二、闭包 对于那些有一点 JavaScript 使用经验但从未真正理解闭包概念的人来说,理解闭包可以看作是某种意义上的重生,突破闭包的瓶颈可以使你功力大增。 闭包是一种特殊的对象。 它由两部分组成。执行上下文(代号A),以及在该执行上下文中创建的函数(代号B)。 当B执行时,如果访问了A中变量对象中的值,那么闭包就会产生。 在大多数理解中,包括许多著名的书籍,文章里都以函数B的名字代指这里生成的闭包。而在chrome中,则以执行上下文A的函数名代指闭包。 因此我们只需要知道,一个闭包对象,由A、B共同组成,在以后的篇幅中,我将以chrome的标准来称呼。 // demo01 function foo() { var a = 20; var b = 30; function bar() { return a + b; } return bar; } var bar = foo(); bar(); 上面的例子,首先有执行上下文foo,在foo中定义了函数bar,而通过对外返回bar的方式让bar得以执行。当bar执行时,访问了foo内部的变量a,b。因此这个时候闭包产生。 ...

October 1, 2024

数据类型

前言 请讲下 JavaScript 中的数据类型? 前端面试中,估计大家都被这么问过。 答:Javascript 中的数据类型包括原始类型和引用类型。其中原始类型包括 Null、Undefined、Boolean、String、Symbol、BigInt。引用类型指的是 Object。 没错,我也是这么回答的,只是这通常是第一个问题,由这个问题可以引出很多很多的问题,比如 Null 和 Undefined 有什么区别?前端的判空有哪些需要注意的? typeof null 为什么是 object? 为什么 ES6 要提出 Symbol? BigInt 解决了什么问题? 为什么 0.1 + 0.2 !== 0.3? 你如何解决这个问题? 如何判断一个值是数组? … 弱类型语言 因为 JavaScript 是弱类型语言或者说是动态语言。这意味着你不需要提前声明变量的类型,在程序运行的过程中,类型会被自动确定,也就是说你可以使用同一个变量保存不同类型的值 var foo = 42; // foo is a Number now foo = "bar"; // foo is a String now foo = true; // foo is a Boolean now 这一特性给我们带来便利的同时,也给我们带来了很多的类型错误。试想一下,假如 JS 说是强类型语言,那么各个类型之间没法转换,也就有了一层隔阂或者说一层保护,会不会更加好维护呢?——这或许就是 TypeScript 诞生的原因。 对 JavaScript 的数据类型掌握,是一个前端最基本的知识点 null 还是 undefined 定义 undefined 表示未定义的变量。null 值表示一个空对象指针。 ...

October 1, 2024

JavaScript 面试题

共 407 道 JavaScript 面试题。答案默认折叠,便于先自行作答。 1. requestAnimationFrame 与 requestIdleCallback 在渲染优化中的执行时机差异?谁优先触发? 难度:3 · 类型:QA 题目要点 requestAnimationFrame 在浏览器即将进行下一帧渲染之前执行,用于动画与视觉更新,优先级高于渲染阶段后的任务。requestIdleCallback 只会在当前帧所有渲染任务完成且仍有剩余时间时触发,属于低优先级空闲调度机制。因此在同一帧中,rAF 必然先于 rIC 执行。rAF 用于保证帧同步,rIC 用于利用空闲时间执行非关键任务。 参考答案 这个问题本质是理解 浏览器一帧的调度模型。 不搞清楚一帧里发生了什么,很容易回答成“谁快谁慢”这种表层结论。 核心结论先给出: 在有下一帧渲染需求的情况下,requestAnimationFrame 一定优先于 requestIdleCallback 执行。 requestIdleCallback 只会在当前帧还有“空闲时间”时才会触发。 下面从浏览器帧模型拆解。 一、浏览器一帧的基本流程 以 60fps 为例,一帧约 16.6ms。 一次完整渲染循环大致是: 执行宏任务(例如 setTimeout) 清空微任务队列 触发 requestAnimationFrame 回调 进行样式计算(Style) 布局计算(Layout) 绘制(Paint) 合成(Composite) 如果还有时间 → 执行 requestIdleCallback 需要明确一点: rAF 是“下一帧渲染前”的回调 rIC 是“当前帧剩余时间”的回调 二、requestAnimationFrame 的执行时机 requestAnimationFrame 的设计目标是: 在浏览器即将进行下一次重绘之前执行回调。 特点: 每帧最多执行一次 与刷新率同步 在样式计算之前触发 如果标签页不可见,会暂停 执行时机可以理解为: ...