
我修好了我的博客——如何设计一个全新的博客框架
作者分享了个人博客框架迭代历程:从Hexo迁移到Astro的尝试,到发现Elog平台带来的启发,最终思考将Notion作为内容管理系统的新方案。文章重点描述了作者对博客系统的核心需求——解决创作同步问题,以及在探索NotionNext等解决方案过程中遇到的技术限制和思考。
本文深入探讨了JavaScript的基础特性,包括基本数据类型、原型与原型链、this指针、闭包、作用域链等。强调了JavaScript的原型继承机制,null与undefined的区别,以及如何实现继承和使用闭包。还讨论了变量提升、局部死区和性能开销等概念,提供了对JavaScript语言设计的深入理解。
本篇文章着重于讨论 JavaScript 语言一些原始的特性(颇有”祖宗之法不可变“的意味的一些语言设计),不过分地关注其实现细节和设计初衷。
注意,这里只讨论 JavaScript 语言本身的特性,而不关心具体实现上的差别(如 V8 引擎的具体实现或 Node 环境与浏览器环境的 API 差别)。
其他面试相关文章
JS 中有8种(ES6)基础数据类型,包括:
7种基础数据类型(存储在栈中):number
, string
, boolean
, undefined
, null
, BigInt
, Symbol
其中BigInt
和Symbol
是ES6引入的新类型
BigInt
用于存储 number
的双精度浮点数无法存储的大整数Symbol
用于存储独一无二、不会改变的值,每个值都有一个唯一的指针(就算是两个一样的字面量),主要是为了解决可能出现的全局变量冲突的问题1种引用类型(存储在堆中,实际上是指针):object
object
有许多衍生类型,如 Array
, Map
, Set
等,他们的原型都是 Object
-> Function
null
和 undefined
null
和 undefined
是特殊的基本类型,它们没有任何拓展方法。
null
相当于一个卷纸盒里没有卷纸的情况,至少这里还有一个卷纸盒(声明了一个变量,且赋予了null初值)undefined
相当于连卷纸盒都没有的情况(声明了变量,但并没有初始化)除此之外,还有一种情况是 undeclared,它并不是一种数据类型,它代表着已经存在于作用域种但没有初始化的变量,常见于局部死区。
JavaScript 是基于原型的而不是基于类的,面向对象用原型链实现继承而不是类的派生。
JavaScript 对象是动态的属性“包”(指其自己的属性),对象又分为普通对象和函数对象两种。
__proto__
属性,它指向这个对象的原型对象(prototype)只有函数对象有 prototype
(原型对象)属性,它有默认拥有两个属性:constructor
和 __proto__
(默认表示在 log
时不会显示出来)
constructor
属性用于记录实例是由哪个构造函数创建,函数对象的 prototype
的 constructor
默认是它自己,普通对象则是它的构造函数。__proto__
属性指向对象的父类(对象)的原型对象(prototype),直到 Object
的 prototype
属性为 null
,原型链结束.construtor
代表自身(子类自己),__proto__
代表父类(原型链)new
关键字调用的时候才可以成为构造函数。this
里而不是 __proto__
中),而不是其原型链上的某个属性,则必须使用所有对象从 Object.prototype
继承的 hasOwnProperty
方法。在原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要。另外,试图访问不存在的属性时会遍历整个原型链。
以下事实均可以在浏览器环境或Node环境中验证。
constructor
属性,其他顶层的未被遮蔽的父类属性也可以被直接访问,但修改会直接赋值到 this
中,并掩蔽 prototype
中的属性prototype
赋值给 __proto__
时,它寻找 constructor
时会调用它的 __proto__.constructor
,实际上就是 Object
(因为普通对象的__proto__
属性就是Object.prototype
)。prototype
和修改实例的 __proto__
在行为上是等价的(因为**同一构造函数的所有实例的****__proto__
*都指向同一个对象,即构造函数的 prototype
)constructor
代表自身,__proto__
代表父类,因此任何构造函数(或者说任何函数)的constructor
是Function()
,__proto__
则指向Object.prototype
(MHY面试题)明白了原型(__proto__
和 prototype
)的概念和原型链的原理后,我们应该如何编写代码来实现继承呢?
Object.create(obj)
创建对象,允许你指定一个将被用作新对象原型的对象,即将 obj
赋值给 __proto__
使用构造函数,通过 new
关键字指定对象的 __proto__
属性为对应函数的 prototype
属性
new
首先会创建一个新的空对象__proto__
属性赋值为构造函数的 prototype
属性,使得通过构造函数创建的所有对象可以共享相同的原型constructor.apply(obj, args)
运行构造函数,并使其绑定到创建的对象中__proto__
属性或者使用 Object.setPrototypeOf(obj, prototype)
__proto__
或者 prototype
属性赋值来增加原型上的属性注意:原生原型不应该被扩展,除非它是为了与新的 JavaScript 特性兼容。
上面这些方法都只能执行一层的继承,如果我想像其他OOP语言一样拥有很长的继承链要怎么办呢?
最好的办法是使用ES6+提供的 class 语法糖,这里仅作为原型链的训练使用。
比如说我们有三个类,继承关系是:PrimaryStudent
->Student
->Person
。
首先想到的方法如下:
function Person(name, age, gender) {
this.name = name;
this.age = age;
this.gender = gender;
}
function Student(name, age, gender, score) {
Person.call(this, name, age, gender);
this.score = score;
}
function PrimaryStudent(grade, ...props) {
Student.apply(this, props);
this.grade = grade;
}
var xiaoming = new PrimaryStudent(2, '小明', 9, '男', 92);
console.log(xiaoming);
但我们需要注意一点:调用了**Student
构造函数不等于继承了Student
**,而只是在 PrimaryStudent
中运行构造函数添加了对应的属性。
现在创建的对象的原型链是:this
-> PrimaryStudent.prototype
-> Object.prototype
-> null
。
而我们想要的原型链是:this
-> PrimaryStudent.prototype
-> Student.prototype
-> Person.prototype
-> Object.prototype
-> null
。
是的,想必你也想到了,我们可以直接修改 prototype
属性来得到正确的原型链,只是我们必须借助中间对象来实现正确的原型链。
我们将这个过程包装在一个函数中,函数中定义了一个空壳函数F来作为原型链的中间一环(F的constructor
是子类的构造函数,而__proto__
指向父类的原型):
function inherits(Child, Parent) {
var F = function() {};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
}
这样就可以用下面的代码还原正确的原型链:
inherits(Student, Person);
inherits(PrimaryStudent, Student);
// PrimaryStudent -> Student -> Person -> Object
this
是 JS 的关键字之一,是 object
类型自动生成的一个内部对象,只能在内部使用,虽然大体上指的是本身及其所处环境,但根据调用的位置不同实际指向的地方是不同的。
众所周知 function
也是一个特殊的对象,而 this
其实也主要用于函数内部。
在看下面这些例子的时候很难不想到上面原型链中的this
指向哪里,等看完了自然就有了答案。
function doSomething() {
this.a = 20
console.log(this.a)
}
a = 10
console.log(a) // 10
doSomething() // 20
console.log(a) // 20
若函数调用的位置直接位于顶级作用域,就像“光杆司令”,就只能执行默认绑定,绑定到全局环境中(在浏览器中是window
,Node环境中则是global
,严格模式中则是undefined
)
function doSomething() {
console.log(this.a)
}
var obj = {
a: 2,
doSomething: doSomething
}
a = 10
doSomething() // 10
obj.doSomething() // 2
函数调用的位置在对象内部时,函数就有了上下文对象,此时 this
就指向了上下文对象。需要注意的是,这里的上下文指的是直接上下文。
我们回忆使用 new
创建实例的原理,实际上也是 new
内部新建了对象,并把这个对象作为上下文对象调用构造函数,也就是说构造函数中的 this
指向我们创建的对象。
需要注意的,this
**** 指向的上下文和词法作用域产生的闭包是两个不同的概念。
显式绑定可以通过一些方法强制给函数设置 this
指向的对象,这些方法定义在 Function
这个构造函数的原型中。
Function.call(this, thisArg, ...args)
this
参数会在实例调用该方法时自动填入,和 Python 中的 self
很像thisArg
用于指定函数的 this
指针...args
为变长参数列表Function.applay(this, thisArg, argArray)
call
的区别是参数列表是数组形式Function.bind(this, thisArg, ...args)
call
的区别是它会返回一个函数以供调用,而不是直接执行ES6中引入的 =>
函数不受上述规则影响,而是完全由外部环境决定 this
的指向,且不能被手动修改(即对箭头函数使用call
、bind
和 apply
是无效的)。但是它的外部环境(父作用域)仍受制于上述的 this
规则。
简单地说:箭头函数没有自己的 this
,内部的 this
完全等价于外层的 this
,通过查找作用域链来确定 this
的值。
const obj = {
a: 1,
say1: function () {
console.log(this.a);
},
say2: () => {
console.log(this.a);
},
say3: function () {
return () => {
console.log(this.a);
}
}
}
ext = obj.say1;
a = 2;
obj.say1(); // 1
obj.say2(); // undefined, this={}
obj.say3()(); // 1
ext(); // 2
词法作用域(Lexical Context)就是定义在词法分析阶段的作用域。
这个叙述可能不是很清晰,我们换一个说法:词法作用域又称静态作用域,与之相对的是动态作用域。静态作用域在程序编译(词法分析)阶段,变量的作用域就已经按照代码层级确定了;而动态作用域会在代码执行阶段动态地从调用栈的作用域中搜索变量。
举个例子:
var value = 1;
function foo() {
console.log(value);
}
function bar() {
var value = 2;
foo();
}
bar();
// 结果是 1
上述例子很清晰地说明了 JS 是静态作用域(词法作用域)语言,即函数的作用域在函数定义的时候就决定了,查找时根据书写的位置,查找上面一层的代码,也就是 value 等于 1,所以结果会打印 1。(否则的话会按照调用栈查 bar
中的变量,打印 2)。
词法作用域给 JS 带来了以下规则:
知道了词法作用域的基本概念后,我们来看看词法作用域在 JS 中具体体现在了哪些地方吧。
在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。
闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。
最开始闭包被广泛地用于函数式编程语言如 LISP,后来很多命令式编程语言也逐渐开始支持闭包(如臭名昭著的 Javascript)
闭包对于初学者难以理解的地方其实仅仅在于这个著名的”翻译谬误“,将 closure 这个还算形象的名称翻译成了 ”闭包" 这个相对晦涩的汉语名称。闭包实际上就是用来指代某些其开放绑定(自由变量)已经由其语法环境完成闭合(或者绑定)的lambda表达式,从而形成了闭合的表达式,就像关上了访问其中元素的大门一样。
简而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中由于词法作用域的存在,闭包会随着函数的创建而被同时创建。
举个例子:
function makeFunc() {
var name = "Mozilla";
function displayName() {
console.log(name);
}
return displayName;
}
var myFunc = makeFunc();
myFunc(); // Mozilla
闭包允许将函数与其所操作的某些数据(环境)关联起来,这显然类似于面向对象编程。
在面向对象编程中,对象允许我们将某些数据(对象的属性)与一个或者多个方法相关联。因此,通常你使用只有一个方法的对象的地方,都可以使用闭包。
更抽象的说,闭包象征着一种封装思维,将某些属性和方法打包封装起来,可以隐藏细节和保护数据,这样来说如自定义 Hook 等都属于广义的闭包。
以下是一些闭包的用处:
以下是实际应用场景:
总结:如果没有绑定作用域的特殊需求,请不要使用闭包!
如果不是某些特定任务需要使用闭包,在其他函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响。
例如,在创建新的对象或者类时,方法通常应该关联于对象的原型,而不是定义到对象的构造函数中。原因是这将导致每次构造器被调用时,方法都会被重新赋值一次(也就是说,对于每个对象的创建,方法都会被重新赋值)。
需要注意的是,使用原型定义也会引入额外的原型链查找开销,因此这其实是一种 trade-off,只是一般单层查找开销略低于反复构造的开销。
除此之外,闭包的滥用还会导致变量不会随着函数退栈被垃圾回收机制回收,可能导致内存泄漏问题。
比如下面的代码:
// 使用闭包定义公有方法:每次构造新对象都会重新赋值一次方法
function MyObjectClosure(name, message) {
this.name = name.toString();
this.message = message.toString();
this.getName = function() {
return this.name;
};
this.getMessage = function() {
return this.message;
};
}
// 使用原型继承定义公有方法:所有对象共用一个地址(但会带来原型链查找的开销)
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
}
MyObject.prototype.getName = function() {
return this.name;
};
MyObject.prototype.getMessage = function() {
return this.message;
};
在 JS 的执行过程(具体地说是函数运行栈帧的创建过程,这就涉及到 V8 引擎的实现了)中,所有标识符(包括函数和变量)的声明部分会被添加到名为Lexical Environment
的结构体(这实际上就是词法作用域的实际载体,也是作用域链的结点)中,这看起来就像是提升到当前作用域的最前面,所以这些变量和函数能在它们真正被声明之前使用。
而根据变量类型的不同,具体的提升行为也不同,但大体上都是只提升声明部分。
sayHi() // Hi there!
function sayHi() {
console.log('Hi there!')
}
var
变量提升:JavaScript在编译阶段会找到 var
关键字声明的变量会添加到词法环境中,并初始化一个值 undefined
(而不是用户定义的赋值初始化行为),在之后执行代码到赋值语句时,会把值赋值到这个变量var name // 声明变量
name = 'John Doe' // 赋值操作
let
& const
变量提升:只有使用 var
关键字声明的变量才会被初始化 undefined
值,而 let
和 const
声明的变量则不会被初始化值,因此在 JavaScript 引擎在声明变量之前,无法访问该变量。这就是我们所说的 Temporal Dead Zone(局部死区),即变量创建和初始化之间的时间跨度,它们无法访问。class
提升:ES6中的新关键字,同样会被提升,但在实际声明前是不能调用的(这点与构造函数不同),也存在局部死区问题。let peter = new Person('Peter', 25) // ReferenceError: Person is not defined
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
let john = new Person('John', 25);
console.log(john) // Person { name: 'John', age: 25 }
根据规则2:只有函数才能制造作用域结构,那么只要是代码,至少有一个作用域,即全局作用域。
凡是代码中有函数,那么这个函数就构成另一个作用域。如果函数中还有函数,那么在这个作用域中就又可以诞生一个作用域,那么将这样的所有作用域列出来,可以有一个结构:函数内指向函数外的链式结构。
举个(相同的)例子:
function foo() {
console.log(a); // 2
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();
这段代码的作用域链如下图所示:
在搜索变量时,只会按照作用域链向上搜索,而词法作用域(静态作用域)又决定了其只与在代码中定义的地方有关,所以无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。
作者: ChlorineC
创建于: 2023-05-05 16:00:00
更新于: 2025-01-11 21:00:00
版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
作者分享了个人博客框架迭代历程:从Hexo迁移到Astro的尝试,到发现Elog平台带来的启发,最终思考将Notion作为内容管理系统的新方案。文章重点描述了作者对博客系统的核心需求——解决创作同步问题,以及在探索NotionNext等解决方案过程中遇到的技术限制和思考。
前端模块化的演变包括CJS、AMD、CMD、UMD和ESM,现代浏览器支持ESM,webpack负责模块处理和打包,确保在浏览器中高效运行模块,支持代码拆分和Tree Shaking以优化性能。
本文探讨了前端开发的复杂性,提倡回归以后端为中心的架构,强调使用简单的工具和技术,如模板引擎、jQuery、alpine.js和htmx,来简化开发流程。作者认为,现代前端工具链的复杂性逐渐偏离了简化开发的初衷,建议在不引入过多复杂性的情况下,利用Web Components等标准技术来实现可复用组件,从而提高开发效率。
本文总结了作者在前端开发领域的学习与成长历程,从大二的项目学习到2023年的实习经历,强调了通过项目实践和面试驱动学习的重要性。作者探讨了前端技术的广度与深度,认为前端并未“死亡”,而是面临着技术的两极分化,未来的发展方向包括拓展Web技术栈和探索全栈开发。文章还提出了大前端技术栈的横向与纵向拓展策略,鼓励前端工程师不断学习与适应新技术。