前端时间阅读mdn,突然发现读下去就一发不可收拾,内容越看越多,越发觉得这门语言离我很远,归根结底,在于以前掌握的那些知识碎片化太厉害,以至于抓住JavaScript概念的一头往外牵拉,梳理不出明确的脉络结构,所以趁有些空闲时间,通过阅读mdn梳理下JavaScript的知识体系,把根扎牢,再读一些比较精深的文章,开枝散叶,重新认识这门编程语言。
什么是JavaScript?
当我们在问一个东西是什么的时候,一定是在问他的本质,即此物之所以为此物而非其他物的本质特性,mdn中解释得很清楚了:
JavaScript是基于原型编程、多范式的动态脚本语言。
"基于原型编程"即是说,JavaScript中的继承关系是通过原型链实现的,对象可以通过原型链继承另一个对象的属性和方法,而不是通过显式的构造函数。
"多范式"即是说,JavaScript可以支持多种不同的编程范式来编写程序,比如面向对象编程、命令式编程、声明式编程、函数式编程。
"动态"是说,JavaScript的变量是可以在运行时动态改变的,因而再JavaScript中,变量没有固定类型,这种动态类型特性使得JavaScript在处理数据时更加灵活和高效。
"脚本语言",JavaScript是在解释器中逐行解释执行,也可以在命令行中执行,所以是脚本语言。
JavaScript大致可以分成三部分:ECMAScript、文档对象模型(DOM)和浏览器对象模型(BOM)
而主要需要学习、理解和消化的部分就是ECMAScript,至于说DOM和BOM,其内容基本靠记忆,知道怎么用即可,我觉得过度研究是没有必要的。
当然,孰轻孰重,但凭个人决定。
JavaScript的优缺点
JavaScript最大的特点就是风格广泛,个人理解这个特点和JavaScript匆匆上线以及JavaScript创作者的知识广度分不开。
JavaScript部分语法来源于java,函数来自于scheme,原型继承来自于selef。
其最大的优点在于容易上手学习,因为JavaScript本质上很多复杂的概念都会用看起来很简单的方式体现出来,比如回调函数。
JavaScript开发者只是简单地使用这些特性,并不关心语言内部的实现原理。
对应地,JavaScript最大的缺点也暴露了出来------要真正掌握JavaScript是很难的,由于JavaScript本身很容易上手,大部分经过初步学习的开发者就可以编写功能全面的程序,而不需要像c++那样对程序有很深入的了解,因而很多人对JavaScript的学习态度就是维持现状,虽然能够胜任日常工作,但这样就很难真正掌握JavaScript。
这也是我目前存在的问题,知识碎片化格外严重,很多东西只是停留在会用、会写的阶段。
因此,在学习JavaScript的过程中,不仅只满足于代码正常工作,还要弄清楚"为什么"的根源问题。
要知其然,也要知其所以然,更要有侧重点和针对性,做到有轻有重,将JavaScript整体的知识结构化出来。
应该用怎样结构化的逻辑脉络,去覆盖JavaScript呢?
单纯将JavaScript分成ECMAScript、DOM和BOM没有丝毫意义,因为ECMAScript中的知识体量非常多,糅杂在一块儿就会变成一团浆糊。
最好的脉络,应该从JavaScript的特性出发,前面说过JavaScript是解释型语言,解释型语言就是通过解释器,当代码执行时逐条翻译成机器码。
但更准确的说法,它是即时编译(JIM)的语言,它在执行前会被即时编译成机器码,而不像传统编译语言那样预先编译成机器码,JavaScript的编译和运行是连续进行的,JavaScript在执行前一定会先经过编译,而编译后也会立即进入执行。
这种即时编译使JavaScript可以在运行时动态优化和改进代码的性能(比如热点代码更新),所以去学JavaScript,不仅仅学它的运行时,还要理解它的编译阶段,这是非常重要的,是串联后面很多知识点的关键。
所以学习JavaScript最好的脉络结构是:编译时+运行时
编译时
在传统编译语言中,程序中的源代码要经历三个阶段:词法分析、语法分析、生成代码和执行。
下面简述这三个阶段:
词法分析
这个过程会从左往右扫描JavaScript源代码,拆解成字符一个一个地读入,根据构词规则生成有意义的词法单元,即token。
例如:
css
var a = 2
这段程序会分解成为下面这些词法单元:
css
var
a
=
2
语法分析
语法分析会识别词法单元组成的各类语句或表达式,并转换成一个由元素逐级嵌套所组成的代表程序语法结构的树,即抽象语法树(AST)。
代码生成
将AST转换为可执行代码的过程称被称为代码生成,简单来说就是有某种方法可以将var a = 2;的AST转化为一组机器指令。
执行机器指令,就进入运行时阶段,比如上述代码中,创建一个叫作a的变量(包括分配内存等),并将一个值储存在a中。
因而我们在学习JavaScript也会大致分成三个部分:词法分析、语法分析、代码生成和运行时。
词法
在编程语言中,词法规定了语言的最小语义单元,即token,词法可以大致分为空白字符、换行符、注释和词,而其中词又分为标识符(变量名和关键字)、符号(运算符等)、数字直接量、字符串直接量和字符串模板。
编程语言的词法结构就是一套规则,这个规则用来描述如何使用这门语言来编写程序,它规定了变量名是什么样的,如何书写注释,语句之间如何分隔等。
字符集
JavaScript程序是用Unicode字符集编写的,而Unicode支持地球上几乎所有在用的语言。
JavaScript中的字符是区分大小写的,并且会忽略程序中token之间的空格,很多情况下会忽略换行符,由于可以在代码中使用空格和换行,因此可以采用整齐统一的编码风格,提高代码可读性。
注释
这个就不用说了,单行注释多行注释。
直接量
也叫字面量,就是程序中直接使用的数据值,比如12、"abc"、true、null等等。
标识符
包括变量名和保留字。
语法和语义
语法和语义部分内容可能会比较多,语义告诉你它是什么,语法告诉你它怎么用,语法语义大体上简要涵盖了JavaScript的知识点,这个阶段只要知道是什么,怎么用即可。
先说语法,JavaScript语法是由一系列的规则和约定组成的,用于定义代码的结构,比如变量声明、函数定义、条件语句等等,这些规则结构构成了JavaScript的语法。
语义指的是代码的含义,它描述的是代码中的元素(变量、函数、控制流语句等)的含义和作用。
在JavaScript中,词法和语法都是描述语言中的规则和结构,但概念和用途不同。
词法是描述程序语言的字符和符号,比如各种标识符、关键字、操作符、分隔符等,用于确定程序中个个字符和符号的语义和作用,决定了程序中的语法结构。
而语法这是用来描述语言的语义规则,包括程序的各个组成部分,比如变量、函数、语句、表达式等,用于确定程序中各个部分之间关系的规则,决定了程序的结构和行为。
这就类比于汉字,"猫"、"坐"、"在"、"草"、"垫"、"上",这六个字通过偏旁部首组合成汉字,我们知道它们都是合法的汉字,但要把组合成一句话,就要通过语法的规则去组合,"草"不能坐在"猫"身上,只有"猫坐在草垫上"是合法的句子,这就是语法,而语义则表示这个句子本身的含义,跟语法结构无关,即猫坐在草垫上这个现象,可以用中文表示,又可以用英语或日语表示。
JavaScript的语法主要包括:
-
变量声明:使用关键字var,let或const声明变量,其语义就是为其分配一个名称和数据类型。
-
表达式:使用运算符和操作数来执行计算,并返回一个值。
-
控制流语句:可以控制程序流程的语句,如条件语句(if、switch、while等)、循环语句(for、for-in、for-of等)、跳转语句(break、continue、return等)
-
函数定义:使用关键字function来定义函数,为其分配一个名称和参数列表,函数可以返回一个值或执行一些操作。
-
对象和数组:使用对象和数组来存储和处理数据,对象是由键值对组成的集合,而数组则是由一组有序的元素。
-
字符串和正则表达式:字符串和正则表达式来处理文本数据,字符串是字符序列,正则表达式则用于匹配和替换文本模式。
-
模块化:使用模块化编程组织和管理代码。
-
事件处理程序,使用事件处理程序来响应用户输入或其他事件,比如点击按钮等。
等等,只要在JavaScript写出来的代码,都可以归为语法和语义。
标识符
标识符就是用来标识具体对象的名称,最常见的标识符就是变量名,保留字也属于标识符。
值、变量和类型
值
值是存储在变量中的数据,可以是任何类型的数据,比如数字、字符串、null等,值是变量所代表的数据内容,通过变量名进行引用,值即字面量。
变量
变量是用来存储值的容器,它是特殊的标识符,用于标识一个值,变量可以通过赋值来存储值,也可以通过变量名来访问和操作值。
在JavaScript中,通过var、let、const和function声明变量,在引擎编译JavaScript程序时会获取所有被var和function声明的变量,会提升到代码的头部,即变量提升(预编译阶段)。
类型
类型是值的属性,定义了值的范围和用途,JavaScript中的类型有8种,包括七种原始数据类型(数字、字符串、布尔值、null、undefined、symbol和bigint)和一种引用数据类型(object,当然,还有array、set、map,但在这里都归为引用数据类型)
原始数据类型的值是存储在栈中的,而引用数据类型则是存储在堆中的,并在栈中存储指针用于指向这个数据。
这就表示栈最多只能有一个引用,堆可以有多个引用。
多数情况下,基本类型直接代表了最底层的语言实现,所有基本类型的值都是不可改变的,但需要注意的是,基本类型本身和一个赋值为基本类型的变量的区别:变量会被赋予一个新值,而基本类型不能像数组、对象以及函数那样被改变。
那么既然如此,基本类型应该是没有方法的,但它们仍然表现得像有方法一样,实际上这是包装对象在起作用,具体后面会讲。
这里大概了解下,当访问基本值的方法或属性时,JavaScript会自动将值装入包装器对象中,并访问该对象上的方法或属性。
例如:
arduino
"foo".includes("f")
这里隐式创建了一个String包装对象,并在该对象上调用String.prototype.includes(),这种自动装箱行为在JavaScript代码中是无法观察到的,但却能很好理解为什么"改变"基本类型不起作用(因为str.Foo = 1不是赋值给str本身的Foo属性,而是赋值给了一个临时包装器对象)。
对引用数据类型来说,不应分为数组、对象、函数等,实质上引用数据类型只有对象。
运算符
运算符按照特定的运算规则对操作数进行运算,得出新的值。
算术运算符
除了对数字进行算术运算外,算术运算符会将非数字隐式转换为数字,比如+"5"的结果就是5。
并且还可以用作字符串的拼接。
关系运算符
根据关系的实际情况返回true或false。
比较大小的关系运算符(>、<、>=、<=)一般会进行数字转化并比较,除去比较大小关系的关系运算符外,还有如下常用的关系运算符:
in 左侧为字符串或可以转化为字符串的表达式,右侧为对象,如果对象拥有左侧名称一样的属性名,则返回true。
=== ==判断值是否全等和相等。
instance 左侧为对象,右侧为类,如果该对象是该类的实例,则返回true,计算o instance of f,JavaScript首先计算f.prototype,然后在原型链中查找o,如果找到,那么o就是f的一个实例。
逻辑运算符
逻辑运算符&&、||、!
赋值运算符
=、+=、-=、*=、/=、%=等等
其他运算符
delete 用于删除对象属性或数组元素,使用delete删除数组元素需要慎重,因为它不会改变数组长度,而会在删除元素的位置补上undefined。
new 创建新对象。
typeof 判断类型,基本可以判断所有基本类型,但对于引用数据类型,function是个例外。
对象
对于前面那些数据类型,大多都心里有个低,也不用拿来单独列几个章节了,当去繁从简,梳清脉络为主。
但对象却是不得不提的,它对于JavaScript非常之重要,以至于不得不在这里将它单独提炼成章节来做介绍。
创建对象
创建一个对象的方法常用的有三种:字面量、Object.create和new。
对象属性及属性的操作
在谈到对象属性时,往往会误认为这些属性值存储在对象内部,但这只是表现形式,在引擎中,属性值并不会存在对象容器内部,存储在对象容器内部的是这些属性的名称,类似指针一样指向这些值真正的存储位置,从本质上来说,属性就是引用。
对于属性操作,暂时记住这些方法或运算符即可:
- 删除属性:delete
- 检测属性:in、hasOwnProperty,前者会迭代查找原型链上的属性,后者只找自身属性。
- 枚举属性:for/in,思考为什么for/of不能用于对象?
- 存取器属性:ES5中,属性值可以用一个或两个方法替代,这个两个方法即getter和setter,由getter和setter定义的属性称为存取器属性。getter和setter是隐藏函数,分别在获取属性值设置属性值时调用。
- 属性特性:属性的特性有4个,即value(数据属性,默认为undefined)、writable(可写性,默认为true)、enumerable(可枚举型,能否通过for/in遍历)、configurable(可配置性,是否能修改属性特性)。存取器属性不具有值特性和可写性,它们的可写性是由setter方法存在与否决定的,因此存取器4个特性是读取get、写入set、可枚举和可配置性。属性特性可通过Object.getOwnPropertyDescriptor来获取对象的某个属性的属性特性。
对象特性
对象有三个特性,原型、类和可扩展性。
原型
JavaScript中每个对象一般都和另一个对象相关联,这另一个对象就是原型,每个对象都从原型上继承属性。
字面量创建对象的方式,具有同一个原型Object.prototype,通过new构造函数的方式,原型即是构造函数的prototype,Object.create则使用第一个参数作为对象原型。
但不是所有对象都有原型对象,比如Object.prototype。
原型之中往往是通过链式连接,比如new Date()继承Date.prototype,而Date.prototype继承Object.prototype,这样形式组成的链就是原型链。
检测一个对象是否是另一个对象的原型:isPrototypeOf。
javascript
var hello = {}var song = Object.create(hello)console.log(hello.isPrototypeOf(song))
prototype是对象的特殊内置属性,是对其它对象的引用,所有prototype根据原型链都会指向Object.prototype,因此其包含JavaScript中许多通用的功能。
因此,给一个对象设置属性,不仅仅是添加新属性或修改已有属性,比如给对象o的属性x赋值,若o中已经有x,则设置该x的值,若o中不存在属性x,则添加一个属性x并赋值,若o是继承的x属性,则会检查原型链并判断是否可以赋值操作,x若为只读则赋值不被允许,若允许赋值,赋值操作也只会在原始对象上创建属性并赋值,而不会修改原型链。当x是setter,那就会调用这个setter,而不会添加到o
上,也不会重新定义x。这个特性即屏蔽,因此在JavaScript中,只有查询属性才会体会到继承的存在,设置属性则和继承无关,这是JavaScript的重要特性,这个特性决定了对象的多样性(Date、Promiese),可以让程序员有选择地覆盖继承属性。
而对于引用类型数据的修改,则会修改到原型链上的属性。
类
这个描述对象特性的类和面向对象语言中的类要区分开,这个类实际上就是字符串,用以标识对象的类型信息,只有一种方法可以间接查询该值,默认的toString方法,即继承自Object.prototype,会返回如下格式的字符串:[object Class]。
因此获取对象的类,可以调用对象的toString方法,然后通过正则去获取。
javascript
console.log(Object.prototype.toString.call(cut).replace(/^\[object (\S+)\]$/, '$1'))
但对象的toString很多都被重写了,因此需要间接调用:Object.prototype.toString.call(o)其结果往往是包装对象的名称,诸如Date、Regexp、Array、Window、String等等
可扩展性
对象的可扩展性表示是否可以给对象添加新属性,所有内置对象和自定义对象都是可扩展的。
可以通过Object.isExtensible方法判断对象是否可扩展。
不可扩展:如果要转化为不可扩展,需要调用Object.preventExtensions(),注意一旦将对象转换为不可扩展就无法再将其转换为可扩展了。
封闭:Object.seal()除了能把对象设置为不可扩展外,还会将所有属性设置为不可配置,即不能添加新属性,原有属性也不能删除或配置,不过对属性可写,通过Object.isSealed()检测对象是否封闭。
冻结:Object.freeze()严格锁定对象,不可扩展、不可配置、不可写,通过Object.isFrozen()来检测对象是否冻结。
序列化对象
对象的序列化指的是将对象的结构状态转换为字符串描述,在ES5中提供JSON.stringfy方法用于将对象序列化,JSON.parse还原Javascript对象。
JSON语法是JavaScript语法的子集,并不能表示JavaScript里的所有值,NaN、Infinity和-Infinity序列化结果为null、函数、RegExp和Error以及undefined无法序列化,会被忽略掉。
如何赋值一个对象,作为JavaScript初学者常见问题。
对于浅拷贝而言,复制的对象中,简单类型的属性值会直接复制,但引用类型的值只是复制引用,和原先对象中属性值引用的对象是一样的。
对深拷贝而言,复制对象属性时,还要复制引用和值。对于json安全的对象,即可以序列化为一个json字符串,并且可以根据这个字符串解析成一个结构和值完全一样的对象,有一种巧妙的复制方法:var newObj = JSON.parse( JSON.stringify( someObj ) );浅拷贝非常易懂且问题很少,ES6定义了Object.assign方法实现浅拷贝,参数一是目标对象,之后可以添加多个源对象。
它会遍历多个源对象的所有可枚举的自有键,并将其复制到目标对象,最后返回目标对象。
ini
var newObj = Object.assign( {}, myObject );
类
和前面表示对象特性的类区分开,这里的类描述了一种代码的组织结构形式:一种反映真实世界的建模方法。
面向对象编程强调的是数据和操作数据的行为互相关联,好的设计就是把数据和它相关联的行为封装起来,即数据结构。
比如一串字符通常被称为字符串,字符就是数据,但往往关心的不是数据是什么,而是可以对数据做什么,所以可以应用在这种数据上的行为,比如计算长度、添加数据等等,都应该被设计成String类的方法。
在ES6以前JavaScript只有通过构造函数去模拟类,ES6新增class关键字,但实际上也只是类的语法糖,JavaScript从来没有出现类的概念,只是一种可以理解为"类"的设计模式。
JavaScript和面向类语言不同,它没有类,只有对象,JavaScript通过prototype的共有且不可枚举的特性来模仿类。
lua
function Foo() { // ... }var a = new Foo();console.log(Foo.prototype.isPrototypeOf(a)) //true
这段代码通过new 构造函数的方式实例化一个对象,new会生成一个新的空对象,并将内部的this指向这个对象,然后将对象的prototype关联到Foo的prototype上,最后return出来,这是new的执行流程。
继承
几种类继承的方式如下:
原型链继承
原型链继承即简单利用原型的关联进行继承:
scss
function Parent(name){ this.name = name; this.like = [1,2,3]}function Son(){};Son.prototype = new Parent('song');son1 = new Son();son2 = new Son();son2.like.push(99)console.log(son1.like)
当son2对属性进行修改时,所有实例的like属性都被修改了。
在原型链继承中,子类继承父类的方式是调用一次父类,实例化一个对象并关联子类的prototype,那么子类在实例化对象后,原型都会指向这个prototype。
虽然原型链继承可以完成继承关系,但由于父类只调用一次,因而Son.prototype中存在引用类型的数据时,当实例修改这个引用类型的数据,其他实例化对象也会受到影响。
且由于父类调用一次,子类在实例化时无法向父类传递参数。
借用构造函数继承
为了解决原型链继承的问题,借用构造函数继承会在子类的实例上创建父类的属性。
scss
function Parent(){ this.like = [1,2,3]}function Son(){ Parent.call(this)};son1 = new Son();son2 = new Son();son2.like.push(99)console.log(son1.like)console.log(son2.like)
运行结果:
[ 1, 2, 3 ]
[ 1, 2, 3, 99 ]
由于每次实例化子类,都会执行一次父类,执行父类的结果是给每个实例对象上添加属性,也就是说这个属性不是原型上的属性,而是实例对象自身的属性,因此修改不会影响到其他实例,并且在子类实例化对象时还可以进行传参。
它解决了原型链继承的问题,即可以传递参数,修改属性不会影响到其他实例
但只能继承父类的实例属性和方法,而无法继承父类的原型上的属性和方法。并且无法复用,每个子类实例相当于"复制"了父类实例的函数,影响性能。
组合继承
组合继承就是综合原型继承和借用构造函数继承,即用原型链实现原型属性和方法的继承,借用构造函数继承实现实例属性的继承。
scss
function Parent(){ this.like = [1,2,3]}Parent.prototype.Say = function(){ console.log(this.like)}function Son(){ Parent.call(this)};Son.prototype = new Parent()Son.prototype.constructor = Sonson1 = new Son();son2 = new Son();son2.like.push(99)son1.Say()son2.Say()
运行结果:
[ 1, 2, 3 ]
[ 1, 2, 3, 99 ]
组合继承父类执行了两次,第一次执行给子类的prototype写入属性like并关联父类的prototype,第二次执行是给实例对象创建属性like,并且在第一次执行时,由于子类prototype中的constructor变为父类,因此需要手动更换回来。
优点在于组合了原型继承和借用函数继承,中和了两者的缺点。
缺点在于:原型中复制了父类的实例,即这里的like属性在对象实例和prototype中各有一份。
寄生组合式继承
由于组合继承中的父类调用了两次,即在实例化调用一次,指定子类原型时调用一次,父类中的属性势必会在子类原型和实例化对象中各"复制"一份,因此寄生组合式继承就是为了解决这个问题。
matlab
function Parent() { this.like = [1, 2, 3] } Parent.prototype.Say = function () { console.log(this.like) } function Son() { Parent.call(this) }; // Son.prototype = new Parent() Son.prototype = Object.create(Parent.prototype) Son.prototype.constructor = Son son1 = new Son(); son2 = new Son(); son2.like.push(99) console.log(son1,son2)
运行结果:
[ 1, 2, 3 ]
[ 1, 2, 3, 99 ]
在指定Son.prototype的原型时不再调用父类,而是通过Object.create返回指定对象的原型。
这种继承方式基本解决了以上所有的问题,也被称为经典继承,但依旧存在问题,也就是子类的prototype被重写,因而prototype为空对象,因此实例化对象无法获取到原本子类的原型。
解决方案有两种:一是在重写子类prototype后,手动添加prototype的属性和方法,二是通过圣杯模式
class
s6出现class关键字,作为类的语法糖,通过extends关键字实现继承。
scala
class Parent{ constructor(name){ this.name = name } } class Son extends Parent { constructor(name,age){ super(name) this.age = age } } const song = new Son('song',18)
上述代码使用class关键字创建类,每个类包含特殊方法constructor,即构造函数,该函数用于创建和初始化由class创建的对象。通过extends来实现子类对父类的继承,super()方法用于调用父类的构造函数,这样就可以访问父类的属性和方法,当super为方法时即父类的构造方法,当super为对象时则为父类。
另外,在class中有个关键字static,即在类中定义一个静态方法,静态方法通过static关键字修饰,又叫类方法,属于类,不属于对象,通过类名直接访问。
class类的继承方式实际上就是模拟的寄生组合式继承,并解决了寄生组合式继承的缺点。
总结来说,类是一种设计模式。
许多语言提供了对于面向类软件设计的原生语法,JavaScript也有类似的语法,但是和其他语言中的类完全不同。
类意味着复制,传统的类被实例化时,它的行为会被复制到实例中,类被继承时,行为也会被复制到子类中。
JavaScript无法完全模拟类的复制行为,只能复制引用,而无法复制被引用的对象或者函数本身。