ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写JS原生方法:从map、bind到Promise的深度实现与原理剖析

手写JS原生方法:从map、bind到Promise的深度实现与原理剖析 1. 项目概述为什么我们要“手写”原生方法“手写JS原生方法”这个标题听起来像是一个面向初学者的练习但如果你这么想可能就错过了它真正的价值。作为一名和JavaScript打了十几年交道的开发者我见过太多人把Array.prototype.map、Promise、Object.create这些方法当成黑盒来用。会用但不知道它们内部是怎么转起来的一旦遇到边界情况或者需要深度定制时就束手无策。这个项目的核心远不止是“重新发明轮子”。它是一次对JavaScript语言核心机制的深度探险。通过亲手实现一遍那些我们每天都在用的map、filter、bind、new甚至是Promise你才能真正理解高阶函数、this绑定、原型链、事件循环这些抽象概念是如何落地的。这就像学开车你当然可以只记住油门、刹车、方向盘但如果你拆解过发动机了解过传动原理你对“驾驶”的理解将完全不同。当你在面试中被问到“如何实现一个call方法”或者在调试一个诡异的this指向问题时这些手写过的代码会成为你大脑里最清晰的“原理图”。它适合所有希望从“API调用者”进阶为“语言理解者”的JavaScript开发者。无论你是想夯实基础、备战面试还是希望在框架底层原理上游刃有余这个练习都是必经之路。接下来我会带你从设计思路到具体实现从核心原理到边界处理完整地走一遍几个关键原生方法的“手写”之旅并分享那些官方文档里不会写的“踩坑”经验。2. 核心思路与设计哲学手写原生方法绝不是对着MDN文档把功能模仿出来那么简单。它背后有一套严谨的设计哲学和实现考量我们需要先理清这些才能写出既符合规范又健壮的代码。2.1 理解“Polyfill”与“教育实现”的区别这是首先要明确的一点。我们做的不是生产环境的Polyfill。一个完整的Polyfill需要严格遵循ECMAScript规范处理所有极端情况考虑性能、兼容性并且通常直接挂载到原生原型上如Array.prototype.myMap。而我们这里的“手写”更偏向于“教育实现”Educational Implementation。我们的目标是清晰、直观地揭示核心原理因此可以适当简化一些非常边缘的场景但必须准确无误地展现核心算法和关键细节。例如我们实现的map方法需要重点展示如何遍历、调用回调、处理this、返回新数组但对于稀疏数组的处理我们可以提及并给出思路但实现可以相对简化以保持核心逻辑的清晰。2.2 核心设计原则在动手之前我们要遵循几个核心原则功能性第一实现的方法必须能正确完成其核心职责。map就是要映射filter就是要过滤。贴合原生API函数签名参数、返回值应尽可能与原生方法一致。这包括参数顺序回调函数、thisArg、返回值类型等。健壮性考量虽然不必像Polyfill那样面面俱到但对于明显的错误输入如回调不是函数需要进行基本的防御性处理这本身也是理解语言的一部分。揭示原理代码结构要能清晰地反映出底层机制。比如实现bind时闭包和函数组合的运用要一目了然。性能意识虽然不追求极致但要避免明显的性能反模式比如在循环中重复计算长度。基于这些原则我们的实现会聚焦于“主干道”用注释和结构来揭示原理同时会专门讨论边界情况和与原生实现的差异。3. 基础方法实现深入数组与函数的内部让我们从最常用的数组方法和函数方法开始这是理解JavaScript函数式编程和this机制的基础。3.1 实现Array.prototype.mapmap方法的本质是“遍历-变换-收集”。原生map会跳过空位并且不会改变原数组。/** * 手写 Array.prototype.map * param {Function} callbackFn - 执行函数接收 (currentValue, index, array) * param {*} thisArg - 可选执行 callbackFn 时的 this 值 * returns {Array} - 由回调函数返回值组成的新数组 */ Array.prototype.myMap function(callbackFn, thisArg) { // 1. 基础校验确保调用主体是数组回调是函数 if (this null) { throw new TypeError(Cannot read property \myMap\ of null or undefined); } if (typeof callbackFn ! function) { throw new TypeError(callbackFn is not a function); } // 2. 将调用主体this转换为对象并获取长度 const O Object(this); const len O.length 0; // 无符号右移确保len为非负整数 // 3. 初始化结果数组 const newArr new Array(len); // 4. 核心遍历逻辑 for (let i 0; i len; i) { // 检查 O 自身是否拥有属性 i处理稀疏数组如 [1, , 3] if (i in O) { // 获取当前元素 const currentValue O[i]; // 调用回调并传入正确的 this 值 (thisArg 或 undefined) const mappedValue callbackFn.call(thisArg, currentValue, i, O); // 将结果存入新数组对应位置 newArr[i] mappedValue; } // 如果 i 不是 O 的自有属性即空位newArr[i] 将保持为 empty // 这与原生 map 行为一致稀疏数组映射后仍是稀疏数组 } // 5. 返回新数组 return newArr; }; // 测试用例 const arr [1, 2, 3]; const result arr.myMap(x x * 2); console.log(result); // [2, 4, 6] const sparseArr [1, , 3]; // 中间有个空位 const sparseResult sparseArr.myMap(x (x || 0) * 2); console.log(sparseResult); // [2, empty, 6] console.log(1 in sparseResult); // false说明第二个位置仍是空位核心要点与避坑指南this的转换在方法内部this指向调用该方法的数组。我们使用Object(this)来确保处理的是对象。length 0这是一个小技巧。无符号右移0位可以将任何值转换为32位无符号整数。如果length是undefined或null会变成0如果是负数会变成一个很大的正数但后续i in O检查会过滤掉。这模仿了原生方法对非法length的处理。i in O这是处理稀疏数组的关键。in操作符检查属性是否在对象及其原型链上。对于数组空位i in O返回false因此跳过执行回调新数组对应位置也保持空位。这是原生map的行为很多手写实现会忽略这一点。callbackFn.call(thisArg, ...)使用Function.prototype.call来显式指定回调函数执行时的this值thisArg。如果thisArg未提供则call的第一个参数是undefined在非严格模式下回调函数内的this会指向全局对象在严格模式下为undefined这与原生行为一致。注意在实际项目代码中强烈不建议直接修改内置原型如Array.prototype因为这可能引发难以追踪的命名冲突和兼容性问题。这里的挂载仅用于演示和测试。教育实现的代码应独立成函数或在沙盒环境中运行。3.2 实现Function.prototype.bindbind方法用于创建一个新函数当这个新函数被调用时其this值会被绑定到bind的第一个参数其余参数将作为新函数的预设参数。/** * 手写 Function.prototype.bind * param {Object} thisArg - 绑定函数运行时内部的this指向 * param {...*} args1 - 预设的参数序列 * returns {Function} - 一个新的绑定函数 */ Function.prototype.myBind function(thisArg, ...args1) { // 1. 基础校验确保调用者是函数 const originalFunc this; if (typeof originalFunc ! function) { throw new TypeError(Function.prototype.bind - what is trying to be bound is not callable); } // 2. 返回一个新的绑定函数 const boundFunc function(...args2) { // 判断调用场景作为构造函数通过 new 调用还是普通调用 // 通过 new 调用时this 指向新创建的实例此时应忽略绑定的 thisArg // 否则使用绑定的 thisArg const isConstructorCall new.target ! undefined; // 或 this instanceof boundFunc const context isConstructorCall ? this : thisArg; // 合并预设参数和调用时传入的参数 const finalArgs args1.concat(args2); // 执行原函数并返回结果 return originalFunc.apply(context, finalArgs); }; // 3. 维护原型关系关键且易漏的一步 // 为了确保通过 new 调用绑定函数时实例能继承原函数的原型属性 if (originalFunc.prototype) { // 创建一个空函数作为中介避免直接修改 boundFunc.prototype 影响 originalFunc.prototype const EmptyFunc function() {}; EmptyFunc.prototype originalFunc.prototype; boundFunc.prototype new EmptyFunc(); // 等同于 Object.create(originalFunc.prototype) // 更现代简洁的写法boundFunc.prototype Object.create(originalFunc.prototype); } // 4. 返回绑定函数 return boundFunc; }; // 测试用例 function greet(greeting, punctuation) { console.log(${greeting}, ${this.name}${punctuation}); } const person { name: Alice }; const boundGreet greet.myBind(person, Hello); boundGreet(!); // 输出: Hello, Alice! // 作为构造函数测试 function Person(name) { this.name name; } Person.prototype.sayHi function() { console.log(Hi, Im ${this.name}); }; const BoundPerson Person.myBind(null, DefaultName); const instance new BoundPerson(); // this 指向新实例而非 null console.log(instance.name); // DefaultName instance.sayHi(); // Hi, Im DefaultName原型链正确核心要点与避坑指南new操作的判断与处理这是bind实现中最精妙也最容易出错的部分。当绑定函数被new操作符调用时作为构造函数绑定的thisArg应该被忽略this应指向新创建的实例。我们通过检查new.targetES6或判断this instanceof boundFunc来实现。参数合并使用args1.concat(args2)将bind时预设的参数和调用时传入的参数按顺序合并。原型链的维护为了让通过new调用绑定函数创建的实例能访问原函数原型上的方法我们必须正确设置boundFunc.prototype。直接boundFunc.prototype originalFunc.prototype是错误的因为这会导致修改boundFunc.prototype会直接影响originalFunc.prototype。我们使用一个空函数中转或Object.create来建立原型继承关系且不产生直接的引用关联。apply的使用使用apply来调用原函数因为它允许我们以数组形式传入参数列表并指定this值。4. 进阶挑战实现一个简版PromisePromise/A规范核心手写一个完整的、符合Promises/A规范的Promise是面试中的高频难题也是理解异步编程模型的试金石。这里我们实现一个包含核心状态机、then方法和基本链式调用的简版。4.1 Promise的状态机与构造函数Promise有三种状态pending等待、fulfilled成功、rejected失败。状态一旦改变就不可再变。// 定义状态常量 const PENDING pending; const FULFILLED fulfilled; const REJECTED rejected; class MyPromise { constructor(executor) { this.state PENDING; this.value undefined; // 成功的结果值 this.reason undefined; // 失败的原因 this.onFulfilledCallbacks []; // 成功的回调队列 this.onRejectedCallbacks []; // 失败的回调队列 // 定义resolve和reject函数 const resolve (value) { // 只有pending状态可以转换 if (this.state PENDING) { this.state FULFILLED; this.value value; // 执行所有已注册的成功回调 this.onFulfilledCallbacks.forEach(cb cb()); } }; const reject (reason) { if (this.state PENDING) { this.state REJECTED; this.reason reason; // 执行所有已注册的失败回调 this.onRejectedCallbacks.forEach(cb cb()); } }; // 立即执行执行器函数 try { executor(resolve, reject); } catch (error) { // 如果执行器同步抛出错误则Promise直接拒绝 reject(error); } } }核心要点状态不可逆在resolve和reject内部首先要检查状态是否为PENDING。回调队列这是处理异步决议的关键。当then方法在Promise未决议pending时被调用需要将回调函数存入队列等待后续resolve或reject时再触发。立即执行executor在构造函数中同步执行。错误捕获用try...catch包裹executor同步错误直接导致Promise拒绝。4.2 实现then方法与链式调用then方法是Promise的核心它返回一个新的Promise实现了链式调用。class MyPromise { // ... 接上面的构造函数 ... then(onFulfilled, onRejected) { // 2.2.1 参数可选如果不是函数则创建一个“穿透”函数 onFulfilled typeof onFulfilled function ? onFulfilled : value value; onRejected typeof onRejected function ? onRejected : reason { throw reason; }; // 返回一个新的Promise以实现链式调用 const promise2 new MyPromise((resolve, reject) { // 封装一个通用的处理函数 const handle (handler, valueOrReason, isFulfilled) { // 使用queueMicrotask或setTimeout模拟微任务简化版用setTimeout setTimeout(() { try { const result handler(valueOrReason); // 处理返回值 this.resolvePromise(promise2, result, resolve, reject); } catch (error) { // 如果handler执行抛出错误则promise2直接拒绝 reject(error); } }, 0); }; if (this.state FULFILLED) { // 当前Promise已成功异步执行onFulfilled handle(onFulfilled, this.value, true); } else if (this.state REJECTED) { // 当前Promise已失败异步执行onRejected handle(onRejected, this.reason, false); } else if (this.state PENDING) { // 当前Promise未决议将回调加入队列 this.onFulfilledCallbacks.push(() handle(onFulfilled, this.value, true)); this.onRejectedCallbacks.push(() handle(onRejected, this.reason, false)); } }); return promise2; } // 核心解析Promise处理thenable对象和普通值 resolvePromise(promise2, x, resolve, reject) { // 2.3.1 如果 promise2 和 x 指向同一对象以 TypeError 为据因拒绝执行 promise2 if (promise2 x) { return reject(new TypeError(Chaining cycle detected for promise)); } // 2.3.3 如果 x 为对象或者函数 if (x (typeof x object || typeof x function)) { let used false; // 防止多次调用 try { // 2.3.3.1 获取 x.then const then x.then; if (typeof then function) { // 2.3.3.3 如果 then 是函数将 x 作为函数的作用域 this 调用之 then.call( x, (y) { // 成功回调 if (used) return; used true; // 递归解析直到返回值是一个非thenable值 this.resolvePromise(promise2, y, resolve, reject); }, (r) { // 失败回调 if (used) return; used true; reject(r); } ); } else { // 2.3.3.4 如果 then 不是函数以 x 为参数执行 promise2 resolve(x); } } catch (error) { // 2.3.3.2 如果取 x.then 的值抛出错误 e 则以 e 为据因拒绝 promise2 if (used) return; used true; reject(error); } } else { // 2.3.4 如果 x 不为对象或者函数以 x 为参数执行 promise2 resolve(x); } } } // 测试用例 const p1 new MyPromise((resolve) { setTimeout(() resolve(success), 100); }); p1 .then((value) { console.log(value); // success return chain value; }) .then((value) { console.log(value); // chain value return new MyPromise(resolve resolve(another promise)); }) .then((value) { console.log(value); // another promise });核心要点与避坑指南值穿透then的参数onFulfilled和onRejected如果不是函数需要被忽略并创建一个默认的“穿透”函数让值或原因可以传递到链中的下一个then。异步执行Promise的then回调必须是异步执行的属于微任务。我们这里用setTimeout模拟真正的Promise实现使用微任务队列如queueMicrotask、process.nextTick等。resolvePromise方法这是实现Promises/A规范最复杂的部分。它需要处理循环引用检测并拒绝promise2 x的情况。thenable对象如果x是一个对象或函数并且有then方法则尝试将它当作Promise来处理这允许不同的Promise实现互操作。递归解析如果thenable对象的then方法返回的还是一个thenable需要递归解析直到得到一个非thenable的值。调用保障resolve和reject只能调用一次通过used标志位来保证。错误处理在handle函数和resolvePromise中任何同步抛出的错误都需要被捕获并导致返回的Promisepromise2被拒绝。5. 常见问题、边界案例与调试技巧手写过程中你会遇到各种意料之外的情况。下面是一些典型问题和我的排查心得。5.1 稀疏数组的处理陷阱在实现数组方法时稀疏数组如let arr [1]; arr[100] 3是一个常见的坑。原生方法如map、forEach会跳过那些“空位”即未被赋值的索引。我们的实现通过i in O来检查。但要注意for...in循环遍历数组会忽略空位但for...in还会遍历可枚举的非数字属性所以不推荐。而for循环配合in检查是最接近原生行为的方式。一个更隐蔽的坑如果你用O.hasOwnProperty(i)代替i in O在数组原型链被修改的极端情况下虽然不推荐行为会不一致。in操作符检查整个原型链而hasOwnProperty只检查自身属性。为了绝对精确应使用in。5.2this绑定的多重场景在实现bind、call、apply时this的指向需要仔细考虑多个场景普通调用func.bind(obj)()this应指向obj。new调用构造函数new (func.bind(obj))()此时this应指向新创建的实例忽略绑定的obj。我们的实现通过判断new.target来处理。bind后的函数再次bind原生行为是只有第一次bind的thisArg有效。我们的简版实现可能无法完全模拟这一点但核心是理解每次bind都返回一个新函数。箭头函数箭头函数没有自己的this它的this在定义时就已经绑定。对箭头函数调用bind、call、apply试图改变this是无效的。在手写这些方法时我们通常不考虑箭头函数的特殊情况因为这是语言特性。5.3 Promise 实现中的异步时序与递归微任务与宏任务我们用了setTimeout模拟异步但它是宏任务。真正的Promise使用微任务这会导致时序差异。例如在同一个事件循环中微任务会在宏任务之前执行。要深入理解可以研究queueMicrotask或process.nextTick。递归深度与栈溢出resolvePromise方法是递归的。如果thenable对象链非常长例如一个对象的then方法返回另一个thenable对象无限循环可能会导致递归调用栈溢出。生产级的Promise实现需要将递归转换为循环或者有深度限制。thenable对象的恶意行为规范的resolvePromise考虑了thenable对象的then方法可能被多次调用既调resolve又调reject或者调用后再次修改。我们的used标志位就是为了防御这种情况。5.4 调试技巧如何验证你的实现对照原生行为写测试这是最有效的方法。用相同的输入调用原生方法和你的方法对比输出。尤其要测试边界情况null/undefined、稀疏数组、类数组对象、抛出错误的回调等。使用Promise测试库对于Promise实现有专门的测试套件如promises-aplus-tests。你可以将你的MyPromise类适配成该库要求的格式然后运行数百个测试用例来验证合规性。这是检验你实现是否健壮的终极手段。分步调试与日志在关键节点如状态改变、回调入队、出队、resolvePromise的递归入口添加console.log观察执行流程。这对于理解异步代码的执行顺序尤其有帮助。可视化工具对于理解Promise链浏览器的开发者工具如Chrome DevTools的异步堆栈跟踪功能非常强大。虽然对手写代码支持可能不完美但观察原生Promise的执行流程也能给你启发。手写原生方法的过程是一个“钻进去”再“跳出来”的过程。一开始你会纠结于每一行代码的模仿但当你理解了状态机、闭包、递归、事件循环这些核心概念后你会发现你看待JavaScript代码的视角完全不同了。你不再仅仅是一个API的使用者而是逐渐拥有了理解甚至预测其行为的能力。这份能力是应对复杂问题、进行高性能优化和深度调试的坚实基础。我建议你不仅仅停留在阅读而是打开编辑器亲手敲下每一行代码并尝试去破坏它、修改它看看会发生什么这才是学习最有效的路径。
返回列表