前端新人必懂:JavaScript原型链揭秘(从懵圈到上手实战)
- 前端新人必懂:JavaScript原型链揭秘(从懵圈到上手实战)
- 引言:你写的对象到底从哪继承了方法?
- JavaScript原型链到底是个啥
- 原型、构造函数和实例之间的三角关系
- __proto__ 与 prototype 的恩怨情仇
- Object.prototype 是不是所有对象的“老祖宗”
- 为什么数组能用 push,对象却不能?——方法查找机制详解
- 原型链的尽头在哪里?null 的终极地位
- 修改原型的风险与正确姿势
- 危险示范
- 相对安全做法
- 原型污染:那些年踩过的安全大坑
- 性能考量:原型方法 vs 实例方法谁更快?
- 调试原型链的实用技巧(console.log 看不懂?试试这些)
- 手写一个简易继承体系,理解 ES6 class 背后的真相
- 如何优雅地扩展内置对象而不惹祸上身
- 常见误区大扫雷:比如“每个函数都有 prototype 吗?”
- 开发中的真实场景:插件库、工具函数如何利用原型链
- 当原型链遇上模块化:ESM 和 CommonJS 下的表现差异
- 别再被面试官问倒:高频原型链面试题拆解思路
- 让代码更聪明:基于原型链的动态行为注入技巧
- 收个尾:原型链不是“八股文”,是“超能力”
前端新人必懂:JavaScript原型链揭秘(从懵圈到上手实战)
引言:你写的对象到底从哪继承了方法?
先别急着翻书,咱们玩个“找爸爸”的游戏。
打开浏览器控制台,敲两行代码:
constcat={name:'Miao'};cat.toString();// 竟然没报错?你明明只给cat塞了一个name,它从哪偷来的toString?
答案就藏在 JavaScript 的“原型链”里——一条看不见却随时跑出来背锅的继承链。
今天咱们不把概念念成经,而是把它拆成火锅料:一片肉、一颗丸子、一撮菜,涮明白为止。
准备好筷子,开锅!
JavaScript原型链到底是个啥
一句话:原型链就是“对象找不到属性时自动去‘爸爸’、‘爷爷’、‘祖宗’家里翻箱倒柜”的规则。
技术点说,每个对象都暗戳戳地挂着一个内部插槽[[Prototype]](浏览器给你露个接口叫__proto__),指向它的“原型对象”。
原型对象也是对象,也有自己的原型,于是链成一串,直到某位老祖宗null拍桌子喊“别找了,老子没有”。
画成地铁线路图:
cat({name:'Miao'}) ↓ __proto__ Object.prototype ↓ __proto__ null ← 终点站,下车代码验证:
console.log(cat.__proto__===Object.prototype);// trueconsole.log(cat.__proto__.__proto__);// null有图有真相,链就是这么直。
原型、构造函数和实例之间的三角关系
把“原型”想成共享仓库,把“构造函数”想成包工头,把“实例”想成精装房。
包工头手里握着一张图纸(prototype),每造一套房就把钥匙(__proto__)塞进房门,让房子共享仓库里的家具。
functionDog(name){this.name=name;// 每个实例自己的属性}Dog.prototype.bark=function(){console.log(`${this.name}: woof!`);// 共享方法};consthusky=newDog('Husky');constteddy=newDog('Teddy');husky.bark();// Husky: woof!teddy.bark();// Teddy: woof!// 共享同一份 barkconsole.log(husky.bark===teddy.bark);// true三角恋关系图:
Dog.prototype ←→ 共享仓库 ↑ constructor | Dog (函数) | new husky / teddy (实例) ↓ __proto__ Dog.prototype记住三句口诀:
- 函数的
prototype指向仓库。 - 实例的
__proto__指向仓库。 - 仓库的
constructor指回函数。
proto与 prototype 的恩怨情仇
面试高频送命题:
“__proto__和prototype区别是啥?”
标准答案太枯燥,咱们讲段子。
prototype是“包工头的图纸”,只有函数才有;__proto__是“房门的钥匙”,所有对象都有。
前者是“设计图”,后者是“导航仪”。
前者决定“共享什么”,后者决定“去哪找”。
代码版:
functionFoo(){}constf=newFoo();console.log(Foo.prototype);// 图纸在这儿console.log(f.__proto__);// 钥匙在这儿console.log(f.__proto__===Foo.prototype);// 图纸和钥匙对上了但注意:__proto__已 deprecated,生产环境请用Object.getPrototypeOf/Reflect.getPrototypeOf,别写祖传代码被 linter 翻白眼。
Object.prototype 是不是所有对象的“老祖宗”
99% 的对象都认Object.prototype当干爹,但总有叛逆少年。
比如用Object.create(null)造出来的“字典对象”:
constmap=Object.create(null);map.foo=1;console.log(map.toString);// undefinedconsole.log(map.__proto__);// undefined它天生没链子,所以写字典库时常用它,避免踩到hasOwnProperty这些地雷。
结论:Object.prototype不是所有人的爹,但它是“默认爹”。想绝交,就create(null)。
为什么数组能用 push,对象却不能?——方法查找机制详解
push并不是数组对象的私货,而是挂在Array.prototype上的共享武器。
当你写[].push(1)时,引擎流程如下:
- 数组实例
[]自己没有push? - 顺着
__proto__找到Array.prototype,有了! - 调用,完事。
对象{}的原型是Object.prototype,上面没有push,所以报错。
手动把数组的仓库借给对象用:
constobj={};Object.setPrototypeOf(obj,Array.prototype);obj.push(1,2,3);console.log(obj.length);// 3console.log(Array.isArray(obj));// false,外形是对象,灵魂是数组这招叫“借链”,面试吹水可用,生产慎用,否则队友会打你。
原型链的尽头在哪里?null 的终极地位
引擎查找属性时,一路向上,直到:
obj.__proto__===null才死心返回undefined。null就像“宇宙尽头的餐厅”,吃完就没下一站。
验证:
letp={};while(p){p=Object.getPrototypeOf(p);console.log(p);}// 输出:Object.prototype → null → 循环结束修改原型的风险与正确姿势
危险示范
Array.prototype.push=function(){console.log('surprise!');};[].push();// 全体数组一起嗨,第三方库瞬间爆炸相对安全做法
- 只扩展自家构造函数:
functionMyArray(){}MyArray.prototype=Object.create(Array.prototype);MyArray.prototype.push=function(...args){console.log('my push');returnArray.prototype.push.apply(this,args);};- 使用
Symbol避免命名冲突:
constcustomPush=Symbol('customPush');Array.prototype[customPush]=function(){console.log('safe push');};[].customPush();// 不会误伤他人- 冻结原型:
Object.freeze(Array.prototype);// 谁也别想改原型污染:那些年踩过的安全大坑
如果服务端把 JSON 直接merge到全局配置,攻击者注入__proto__字段就能污染所有对象:
constuserData=JSON.parse('{"__proto__": {"admin": true}}');constconfig={};Object.assign(config,userData);console.log({}.admin);// true,惊不惊喜?防御手段:
JSON.parse后判断键名;- 使用
Object.create(null)做 map; - 采用
lodash的mergeWith并过滤__proto__。
性能考量:原型方法 vs 实例方法谁更快?
原型方法只存一份,节省内存;
实例方法每new一次就复制一份,内存爆炸,但少一次“链查找”,理论上更快。
实测说话:
functionProtoBench(){}ProtoBench.prototype.say=()=>{};functionInstanceBench(){this.say=()=>{};}constp=newArray(1e6).fill(null).map(_=>newProtoBench());consti=newArray(1e6).fill(null).map(_=>newInstanceBench());console.time('proto');p.forEach(x=>x.say());console.timeEnd('proto');console.time('instance');i.forEach(x=>x.say());console.timeEnd('instance');Node 18 下结果(单位 ms):
proto: 28 instance: 45原型方法不仅省内存,还更快——因为现代引擎对“链查找”做了内联优化。
结论:别用实例方法硬刚,除非你要做闭包缓存私有状态。
调试原型链的实用技巧(console.log 看不懂?试试这些)
- 树状打印:
functionprintChain(obj){constchain=[];letp=obj;while(p){chain.push(p);p=Object.getPrototypeOf(p);}console.table(chain.map((x,i)=>({level:i,ctor:x.constructor.name,keys:Object.getOwnPropertyNames(x).join(', ')})));}printChain([]);// 一目了然断点调试:
DevTools → Scope →[[Prototype]]直接展开,比console.log翻山越岭香。console.dir:console.dir(husky)勾选 “Show prototypes”,链上每一环都裸奔。
手写一个简易继承体系,理解 ES6 class 背后的真相
ES6class只是语法糖,骨子里还是原型链。
咱们手写一套“继承”复刻:
// 父类functionAnimal(name){this.name=name;}Animal.prototype.speak=function(){return`${this.name}makes a noise.`;};// 子类functionDog(name,breed){Animal.call(this,name);// 借父构造函数初始化属性this.breed=breed;}// 核心:原型链继承Dog.prototype=Object.create(Animal.prototype);Dog.prototype.constructor=Dog;// 修正指针Dog.prototype.speak=function(){return`${this.name}barks.`;// 多态};constd=newDog('Milo','Labrador');console.log(d.speak());// Milo barks.console.log(dinstanceofDog);// trueconsole.log(dinstanceofAnimal);// true对比 ES6:
classAnimal{constructor(name){this.name=name;}speak(){return`${this.name}makes a noise.`;}}classDogextendsAnimal{constructor(name,breed){super(name);this.breed=breed;}speak(){return`${this.name}barks.`;}}Babel 转译后就是上面的function版,毫无魔法。
如何优雅地扩展内置对象而不惹祸上身
需求:给数组加一个“安全求和”方法,但别污染全局原型。
解:用“子类化”+Symbol,组合拳出击:
constsumSym=Symbol('sum');classSafeArrayextendsArray{[sumSym](){returnthis.reduce((a,b)=>a+b,0);}// 提供公开接口sum(){returnthis[sumSym]();}}constarr=SafeArray.from([1,2,3]);console.log(arr.sum());// 6console.log([][sumSym]);// undefined,原生数组不受影响核心思路:
- 继承内置构造函数;
- 用
Symbol做键名,防碰撞; - 公开方法做代理,保持封装。
常见误区大扫雷:比如“每个函数都有 prototype 吗?”
- 误区 1:箭头函数也有
prototype
真相:箭头函数没有prototype属性,也不能new。
constFoo=()=>{};console.log(Foo.prototype);// undefined误区 2:
__proto__是 ECMA 标准属性
真相:只是浏览器事实标准,ES6 起被Object.getPrototypeOf取代。误区 3:修改
__proto__会立刻提升性能
真相:频繁改动原型会 de-opt,引擎会放弃隐藏类优化,性能反而雪崩。
开发中的真实场景:插件库、工具函数如何利用原型链
案例:写一款“链式”字符串工具库,模仿lodash/fp:
functionMyStr(val){if(!(thisinstanceofMyStr))returnnewMyStr(val);this.val=val;}MyStr.prototype.map=function(fn){returnnewMyStr(fn(this.val));};MyStr.prototype.toUpper=function(){returnthis.map(s=>s.toUpperCase());};MyStr.prototype.value=function(){returnthis.val;};console.log(MyStr('kimi').toUpper().value());// KIMI利用原型共享方法,避免每次链式都复制函数,内存占用极低。
再进一步,把MyStr.prototype做成冻结单例,防止外部篡改:
Object.freeze(MyStr.prototype);当原型链遇上模块化:ESM 和 CommonJS 下的表现差异
在 CommonJS 里,每个文件是一个“闭包对象”,如果给Array.prototype加料,会影响整个运行时;
在 ESM 里,行为一致,但静态导入让“污染”更容易被 tree-shaking 误杀——
比如你把扩展方法挂在原型上,却没在模块里显式引用,打包器可能把“看似无用”的扩展剔除。
解决:
- 扩展原型时,在模块里执行一次“自调用”代码,确保副作用保留;
- 或者写
sideEffects: true告诉打包器“别删我”。
别再被面试官问倒:高频原型链面试题拆解思路
题 1:如何让a === 1 && a === 2 && a === 3成立?
思路:利用valueOf在原型链上的动态查找:
constobj=Object.create(null);letval=1;Object.defineProperty(obj,'valueOf',{value(){returnval++;}});consta=obj;console.log(a==1&&a==2&&a==3);// true题 2:说出new的四个步骤
- 创建空对象;
- 链接到构造函数原型;
- 绑定
this执行构造函数; - 返回对象(或构造函数的显式返回)。
题 3:如何判断一个对象是否是“普通对象”?
functionisPlainObject(o){returnObject.prototype.toString.call(o)==='[object Object]'&&(Object.getPrototypeOf(o)===null||Object.getPrototypeOf(o)===Object.prototype);}让代码更聪明:基于原型链的动态行为注入技巧
场景:运行时根据用户权限给按钮注入“点击上报”逻辑,但不想改原组件。
解:把方法注入到原型链的“代理层”,实现无侵入 AOP。
// 原始按钮类functionButton(label){this.label=label;}Button.prototype.click=function(){console.log(`Button${this.label}clicked`);};// 权限装饰器functionwithReport(Base){functionProxyButton(...args){Base.apply(this,args);}ProxyButton.prototype=Object.create(Base.prototype);ProxyButton.prototype.click=function(){console.log('[Report] send log');Base.prototype.click.call(this);};returnProxyButton;}constReportButton=withReport(Button);constbtn=newReportButton('Save');btn.click();// [Report] send log// Button Save clicked利用原型链的“层级差”,实现逻辑插拔,比直接改源码清爽一百倍。
收个尾:原型链不是“八股文”,是“超能力”
很多人学完原型链,只在面试时背概念,写完 class 就再也不回头。
其实它像浏览器留给你的一把瑞士军刀:
- 能做性能优化(共享方法);
- 能做安全隔离(
create(null)); - 能做动态代理(行为注入);
- 甚至能做“元编程”——在运行时重写对象的语言层逻辑。
别害怕它的“古老”,也别被class糖衣迷惑。
真正的“上手”,是多写一行代码、多踩一次坑、多看一次__proto__的指向,然后拍拍键盘:“哦,原来如此。”
到那时,你写的就不再是代码,而是 JavaScript 的“家谱”。
欢迎来到我的博客,很高兴能够在这里和您见面!希望您在这里可以感受到一份轻松愉快的氛围,不仅可以获得有趣的内容和知识,也可以畅所欲言、分享您的想法和见解。
推荐:DTcode7的博客首页。
一个做过前端开发的产品经理,经历过睿智产品的折磨导致脱发之后,励志要翻身农奴把歌唱,一边打入敌人内部一边持续提升自己,为我们广大开发同胞谋福祉,坚决抵制睿智产品折磨我们码农兄弟!
| 专栏系列(点击解锁) | 学习路线(点击解锁) | 知识定位 |
|---|---|---|
| 《微信小程序相关博客》 | 持续更新中~ | 结合微信官方原生框架、uniapp等小程序框架,记录请求、封装、tabbar、UI组件的学习记录和使用技巧等 |
| 《AIGC相关博客》 | 持续更新中~ | AIGC、AI生产力工具的介绍,例如stable diffusion这种的AI绘画工具安装、使用、技巧等总结 |
| 《HTML网站开发相关》 | 《前端基础入门三大核心之html相关博客》 | 前端基础入门三大核心之html板块的内容,入坑前端或者辅助学习的必看知识 |
| 《前端基础入门三大核心之JS相关博客》 | 前端JS是JavaScript语言在网页开发中的应用,负责实现交互效果和动态内容。它与HTML和CSS并称前端三剑客,共同构建用户界面。 通过操作DOM元素、响应事件、发起网络请求等,JS使页面能够响应用户行为,实现数据动态展示和页面流畅跳转,是现代Web开发的核心 | |
| 《前端基础入门三大核心之CSS相关博客》 | 介绍前端开发中遇到的CSS疑问和各种奇妙的CSS语法,同时收集精美的CSS效果代码,用来丰富你的web网页 | |
| 《canvas绘图相关博客》 | Canvas是HTML5中用于绘制图形的元素,通过JavaScript及其提供的绘图API,开发者可以在网页上绘制出各种复杂的图形、动画和图像效果。Canvas提供了高度的灵活性和控制力,使得前端绘图技术更加丰富和多样化 | |
| 《Vue实战相关博客》 | 持续更新中~ | 详细总结了常用UI库elementUI的使用技巧以及Vue的学习之旅 |
| 《python相关博客》 | 持续更新中~ | Python,简洁易学的编程语言,强大到足以应对各种应用场景,是编程新手的理想选择,也是专业人士的得力工具 |
| 《sql数据库相关博客》 | 持续更新中~ | SQL数据库:高效管理数据的利器,学会SQL,轻松驾驭结构化数据,解锁数据分析与挖掘的无限可能 |
| 《算法系列相关博客》 | 持续更新中~ | 算法与数据结构学习总结,通过JS来编写处理复杂有趣的算法问题,提升你的技术思维 |
| 《IT信息技术相关博客》 | 持续更新中~ | 作为信息化人员所需要掌握的底层技术,涉及软件开发、网络建设、系统维护等领域的知识 |
| 《信息化人员基础技能知识相关博客》 | 无论你是开发、产品、实施、经理,只要是从事信息化相关行业的人员,都应该掌握这些信息化的基础知识,可以不精通但是一定要了解,避免日常工作中贻笑大方 | |
| 《信息化技能面试宝典相关博客》 | 涉及信息化相关工作基础知识和面试技巧,提升自我能力与面试通过率,扩展知识面 | |
| 《前端开发习惯与小技巧相关博客》 | 持续更新中~ | 罗列常用的开发工具使用技巧,如 Vscode快捷键操作、Git、CMD、游览器控制台等 |
| 《photoshop相关博客》 | 持续更新中~ | 基础的PS学习记录,含括PPI与DPI、物理像素dp、逻辑像素dip、矢量图和位图以及帧动画等的学习总结 |
| 日常开发&办公&生产【实用工具】分享相关博客》 | 持续更新中~ | 分享介绍各种开发中、工作中、个人生产以及学习上的工具,丰富阅历,给大家提供处理事情的更多角度,学习了解更多的便利工具,如Fiddler抓包、办公快捷键、虚拟机VMware等工具 |
吾辈才疏学浅,摹写之作,恐有瑕疵。望诸君海涵赐教。望轻喷,嘤嘤嘤
非常期待和您一起在这个小小的网络世界里共同探索、学习和成长。愿斯文对汝有所裨益,纵其简陋未及渊博,亦足以略尽绵薄之力。倘若尚存阙漏,敬请不吝斧正,俾便精进!