《重学前端》的学习笔记

极客时间的专栏《重学前端》的学习笔记,持续更新中。

开篇词 | 从今天起,重新理解前端

前端开发之痛:散点自学 + 基础不牢,对于前端的知识体系和底层原理没有真正系统地理解

关于前端工程师成长需要两个视角:

(1)立足标准,系统性总结和整理前端知识,建立自己的认知和方法论;

(2)放眼团队,从业务和工程角度思考前端团队的价值和发展需要。

除此之外,前端工程师还需要了解程序员通用的编程能力和架构能力。

01 | 明确你的前端学习路线与方法

两个前端学习方法:

(1)建立知识架构

将零散的知识组织起来,也能帮助我们发现知识上的误区

(2)追本溯源

关注某个知识点背后的体系

03 | HTML语义:div和span不是够用了吗?

语义类标签的特点是在视觉表现上都差不多,主要区别在于表示了不同的语义。

语义标签的好处:(1)增强网页结构可读性;(2)更适合SEO和读屏软件

语义标签用不好会造成大量的冗余标签,所以“用对”比“不用”好,“不用”比“用错”好

比较重要的语义标签适用场景:

(1)作为自然语言延伸的语义类标签,例如<ruby><rt><rp>

(2)作为消除歧义的标签,例如表示重音的<em>

(3)作为标题摘要的标签,例如<h1><hgroup><section>

(4)作为整体结构的语义类标签。例如<header><footer><aside><address>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<body>
<header>……</header>
<article>
<header>……</header>
<section>……</section>
<section>……</section>
<section>……</section>
<footer>……</footer>
</article>
<article>……</article>
<article>……</article>
<footer>
<address></address>
</footer>
</body>

04 | HTML语义:如何运用语义类标签来呈现Wiki网页?

用Wiki的一个页面举例,学习了语义化标签的使用。

这一节介绍到的语义化标签有:

  • aside,导航性质的工具内容
  • article,有明确独立性的主体部分
  • hgroup/h1/h2,一个标题组中的各级标题
  • abbr,缩写的词语都应该使用abbr标签
  • hr,横向分割线,但是表示故事走向或话题的转变,如果是单纯的视觉效果不应该使用这个标签
  • strong,强调的
  • blockquote,段逻辑的引述内容
  • q,行内的引述内容
  • cite,引述的作品名
  • time,日期,让机器阅读更方便
  • figure/figcaption,与著文章先骨干的、有一定自包含性的内容,都可以用figure包裹,可以是图片、表格、代码等,figcaption是这些内容的标题
  • dfn,包裹被定义的名字,例如:<dfn>程序员</dfn>就是写程序的人
  • nav,目录导航
  • ol/ul,二者的区别是内容是否有顺序关系,不要因为视觉表现夏鸥改变语义
  • pre,表示其中的内容是预先经过排版的,不需要浏览器干预排版(保留了原来的缩进格式)
  • samp:计算机程序的实例输出
  • code:代码,和samp一样,一般都是包裹在pre之中

还有一些其他的标签:

  • date,类似于time,给及其阅读的内容
  • sub/sup,下标/上标,多用于数学、物理、化学领域
  • menuul的变体,用于功能菜单使用
  • main,整个页面出现一个,表示页面的主要内容,可以理解为特殊的div

语义化标签非常多,并且不像严谨的编程语言一样,有一条非此即彼的线。一些语义的引入会带来争议,所以应该尽量只用自己熟悉的语义标签,并且只在有把握的场景引入语义标签,保证语义不被滥用,不带来更多问题。

05 | JavaScript类型的细节

类型

JavaScript中定义了7种类型:

  • Undefined
  • Null
  • String
  • Number
  • Boolean
  • Symbol
  • Object

Undefined/Null

JavaScript中的undefined是一个变量,而非一个关键字。任何变量在赋值之前都是Undefined类型,值为undefined,是一个全局变量,所以有:

1
2
3
4
5
undefined === undefined; // true

let a;
let b;
a === b; // true

void运算的作用是将一个表达式变为undefined

1
2
3
4
5
let a = void 0;
a; // undefined

let b = void {};
b; // undefined

一般不会将变量赋值为undefined,保证所有值为undefined的变量都是从未赋值的自然状态

Null类型也只有一个值null,所以有:

1
null === null; // true

Null语义是定义了,但是为空,这与Undefined是不同的

String

String的最大长度是2^53-1,这个长度指的不是字符数,而是字符串的编码长度

字符串操作charAtcharCodeAtlength针对的都是UTF16编码

UTF是Unicode的编码方式,一个Unicode码点表示一个字符,通常用U+????来表示,其中????是十六进制的码点值,0-65536(U+0000-U+FFFF的码点被称为基本字符区域

在JavaScript中的表示:

1
'\u0031'; // 1

JavaScript字符串把每个UTF16单元当做一个字符串来处理,所以处理超出自己字符区域的字符时需要格外小心。

感觉阮一峰关于字符编码的知识讲的更加详细。

Number

Number类型符合IEEE754-2008规定的双精度浮点数规则,但是为了表达几个额外的语言场景,规定了几个例外情况

  • NaN
  • Infinity,无穷大
  • -Infinity,负无穷大

Infinity-Infinity是为了不让除以0出错而引入的

JavaScript中有0-0的区别,加法类运算没有区别,除法场合需要留意区分,区分的方式就是检测用作除数的结果,是Infinity还是-Infinity

1
2
1/-0 ===-Infinity; // true
1/0 ===Infinity; // true

由于浮点数运算的精度问题,导致非整数得Number不能直接比较:

1
0.1 + 0.2 === 0.3 // false

正确的比较方法是使用JavaScript提供的最小精度之Number.EPSILION

1
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILION // ture

==检查等式左右两边的差是否小于最小精度==,才是正确的比较浮点数的方法。

Symbol

Symbol是一切非字符串的类型key的集合,使用全局的Symbol函数创建Symbol

1
var mySymbol = Symbol("my symbol");

一些特殊的属性可以在全局的Symbol函数的属性中找到,比如可迭代对象的迭代器就定义在了Symbol.iterator属性上

1
2
3
4
5
6
7
8
9
10
11
12
13
let o = {};

o[Symbol.iterator] = function* () {
let index = 0;
while(index < 10) {
yield index++
}
};

for(let i of o) {
console.log(i)
}
// 0 1 2 3 ... 9

更多的内容需要专门学习。

Object

提出了一个问题,为什么给对象添加的方法能够用在基本类型上?

1
2
3
4
Number.prototype.say = function () {
console.log(123)
};
(123).say(); // 123

==这是因为.运算符提供了封箱操作,它会根据基本类型构造一个临时对象,使得我们能够在基本类型上调用对应的对象的方法。==

类型转换

封箱转换

把基本类型转换为对应的对象,装箱独享都有私有的Class属性,可以用Object.prototype.toString来获取。

JavaScript中没有任何办法可以更改私有的class属性,因此Object.prototype.toString是可以准确识别对象对应的基本类型的方法

拆箱转换

ToPrimitive函数是对象类型到基本类型的转换,对象到String和Number的转换都遵循“先拆箱再转换”的规则,拆箱转换会调用valueOftoString类获得拆箱后的基本类型

转换为数字时首先调用valueOf,转换为字符串时首先调用toString

ES6中可以通过显示指定Symbol.toPrimitive来覆盖原有行为

06 | JavaScript对象:面向对象还是基于对象

JavaScript中的对象与其他语言的对象有一些不同之处,例如:

  • JavaScript在ES6以前没有类的概念
  • JavaScript对象可以自由添加属性,其他语言不能

到底什么是面向对象?

面向对象是顺着人类思维模式产生的一种抽象,从人类认知角度,对象应该是下列事物之一:

  1. 一个可以触摸或者可以看见的东西
  2. 人的智力可以理解的东西
  3. 可以指导思考或行动(进行想象或施加动作)的东西

JavaScript没有采用C++、Java等流行的编程语言采用的“类”的方式来描述对象,而是采用了一个更为冷门的方式“原型”来描述对象

但是由于公司正值原因,JavaScript推出时在“原型运行时”的基础上增加了newthis的特性,使之看起来更像Java。

对象的特点

对象有如下的特点:

(1)对象具有唯一标识性:即使完全相同的两个对象,也并非一个同一个对象

对象的唯一标识性是用内存地址来体现的,对象具有唯一标识的内存地址

1
2
3
var o1 = { a: 1 };
var o2 = { a: 1 };
console.log(o1 == o2); // false

(2)对象有状态:对象具有状态,同一个对象可能处于不同状态之下

(3)对象具有行为:对象的状态,可能因为它的行为产生变迁

第二个和第三个特征“状态”和行为,在JavaScript中统一抽象为“属性”(因为JavaScript中函数也是一个特殊的对象)

JavaScript在实现对象基本特征的基础上,JavaScript对象独有的特色是:==对象具有高度的动态性,这是因为JavaScript赋予了使用者在运行时为对象添改状态和行为的能力==。

JavaScript对象的两类属性

JavaScript对象的属性并非只是简单的键值对,而是用一组特征来描述属性

第一类是数据属性(descriptor),有四个特征:value/writable/enumerable/configurable

第二类是访问器属性,也有四个特征(setter/getter/enumerable/configruable

访问器属性使得使用者在读和写属性时,可以执行代码得到不同的值,可以视为一种函数的语法糖

通常定义属性的代码会产生数据属性的value特征值,其他的数据属性的特征值默认为true,使用Object.getOwnPropertyDescripter可以查看数据属性特征值,使用Object.defineProperty可以改变数据属性特征值和访问器属性

在创建对象时,可以使用getset关键字阿里创建访问器属性:

1
2
3
4
5
6
7
var o = { 
get a() {
return 1
}
};

console.log(o.a); // 1

对象是一个属性的索引结构,key是属性名,属性值是一系列描述属性的集合

1
2
3
4
5
6
{
value: 1,
writable: true,
enumerable: true,
configurable: true
}

JavaScript提供了完全运行时的对象系统,可以模仿多数面向对象的编程范式,比如基于类和基于原型,但是由于它的对象系统是独特的、具有高度动态性的对象系统,这让它与其他面向对象的语言不通

07 | JavaScript对象:我们真的需要模拟类吗

早期JavaScript的模拟面向对象,实际上是模拟基于类的面向对象

而JavaScript的面向对象本质上是基于原型的,而由于历史原因,不得不加入了newthis等语言特性来模拟类

JavaScript的原型复制操作采用了引用的思路:一个对象并不是真的去复制一个原型对象,而是使得新对象持有一个原型的引用

原型系统

用两条概括原型系统

  • 对象通过私有字段[[proto]]连接到其原型
  • 读取一个属性,如果对象本身没有,则会继续访问对象的原型,知道原型为空或者找到为止

ES6提供了一些列内置函数,可以更方便的访问、操作原型,使用这三个方法,可以完全抛开类的模拟,直接利用原型来实现抽象、继承和复用。这三个方法是:

  • Object.create(a, b),根据指定的原型(a)创建新对象,原型可以是nullb是新对象的描述属性(数据属性)
  • Object.getPrototypeOf获得一个对象的原型(等同于直接获取对象的__proto__属性)
  • Object.setPrototypeOf设置一个对象的原型(等同于直接设置对象的__proto__属性)

用构造函数模拟类

我们现在用来判断类型的最准确的方法Object.prototype.toString获得的结果[object xxxx],实际上就是早期版本的“类”的概念,它原本是一个私有属性[[class]],从ES5开始,[[class]]Symbol.toStringTag定义,而Object.prototype.toString的结果可以使用Symbol.toStringTag定义:

1
2
var o = { [Symbol.toStringTag]: "MyObject" };
console.log(Object.prototype.toString.call(o)); // [object MyObject]

用构造函数模拟类,关键就是使用new运算符,当我们对一个构造函数执行了new操作,实际上发生了一下几件事:

1
2
3
4
5
6
7
let p = new Person();

// 实际上
let p = {};
p.__proto__ = Person.prototype;
Person.call(p);
return p

new运算的返回值默认返回this,但显式的返回值时,如果返回值的是==基本类型==,则忽略返回值,仍然返回this,如果返回值是==引用类型==,则直接返回该返回值作为对象的结果

new的行为,视图让函数对象在语法上跟类变得相似,它客观上提供了两种范式,一种是在构造器中的this上添加实例属性,另外一种是在构造器的prototype原型对象上添加原型属性

在早期没有Object.create等方法的早期版本中,new是唯一一个可以指定[[proto]]的方法(直接访问__proto__是不被推荐在生产环境使用的)

下面是Object.create简单的Polpyfill:

1
2
3
4
5
function create (prototype) {
function F() {}
F.prototype = prototype;
return new F()
}

ES6中的类class

ES6中的class用一种看起来更符合主流意义上的类的形式来模拟JS中的类,但本质上还是利用原型系统实现继承和复用,只不过用class代替了newfunction的搭配,看起来更协调而已。

推荐使用ES6的语法来定义类,令function回归原本的函数语义。

ES6中引入的class关键字,意味着类的概念从属性升级为语言的基础设施,从此,基于类的编程方式成为了JavaScript的官方编程范式。

在类中通过get/set关键字创建getter/setter,类中定义的方法和属性会被写在原型对象上,数据型成员最好写在构造器里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Rectangle {
constructor(height, width) {
this.height = height;
this.width = width;
}
// Getter
get area() {
return this.calcArea();
}
// Method
calcArea() {
return this.height * this.width;
}
}

类的写法实际上也是由原型运行时来承载的(我认为是原型系统的一种语法糖),逻辑上JavaScript认为每个类是有共同原型的一组对象。

类通过extends提供了继承能力:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Animal { 
constructor(name) {
this.name = name;
}

speak() {
console.log(this.name + ' makes a noise.');
}
}

class Dog extends Animal {
constructor(name) {
super(name); // call the super class constructor and pass in the name parameter
}

speak() {
console.log(this.name + ' barks.');
}
}

let d = new Dog('Mitzie');
d.speak(); // Mitzie barks.

比起早期的继承方式,extends关键字自动设置了constructor,并且会自动调用父类的构造函数,是一种更少坑的设计。

所以当我们使用类的思想来设计代码时,应该尽量使用class来声明类,而不是用旧语法,拿函数类模拟对象

(但是我认为,可以使用新的语法,但是如果不搞清楚本质还是不行的,需要弄清楚JavaScript实现继承和复用的根本原理,搞清楚JavaScript中原型链的原理,这是根本)

(这一节的内容实际上信息量很大,讲述的是JavaScript中最基础、最精华的部分,设计能力的提高离不开这部分基础,之前自己总结过相关的笔记《Javascript面向对象编程2–构造函数的继承》,需要反复的思考和复习,打好基础)

08 | JavaScript对象:你知道全部的对象分类吗?

这两天,offer的事情有了着落,又刚过完年回来,工作又闲的冒泡,自我驱动能力大幅下降,有点控制不住自己了

关于这一章的内容,实际上是比较复杂且深奥的,需要好多储备知识才能大概理解,而前几章我一直有一个不太满意的点,就是感觉作者的思路,或者说是语言组织,有一种故作高深的感觉,把简单的东西搞的很复杂的样子,把不好理解的知识用一些更不好理解的语言讲解,让人理解起来更加费劲,而且思路常常碎片化。

比较之下,更显得阮一峰的难得,举重若轻,平易近人,这才是我学习的榜样。

可以把对象分成几类:

(1)==宿主对象==,有JavaScript宿主环境提供的对象,它们的行为完全由宿主环境决定

(2)==内置对象==,有JavaScript语言提供的对象,包括:

  1. 固有对象,有标准规定,随着JavaScript运行时创建而自动创建的对象实例
  2. 原生对象,可以由用户通过Array,RegExp等内置构造器或者特殊语法创建的对象
  3. 普通对象,由{}Object构造器或者class关键字创建的对象,能够被原型继承

宿主对象

JavaScript中常见的宿主对象就是浏览器提供的宿主对象,在这个宿主对象中,全局对象是windowwindow上有很多属性,比如document

实际上,window对象上的属性一部分来自浏览器环境,另外一部分来自JavaScript语言(JavaScript的标准中规定了全局对象属性)

宿主对象也分为固有的和用户可创建的两种,比如document.createElement就可以创建一些dom对象

宿主也会提供一些构造器,比如可以使用new Image创建<img>元素

内置对象·固有对象

固有对象在任何JavaScript代码执行前就已经被创建出来了,它们通常扮演者饿类似基础库角色,ECMA中规定的固有对象有150+个

内置对象·原生对象

我们将JavaScript中,能够通过语言本身的构造器创建的对象作为原生对象。JavaScript提供了30多个构造器,可以分成以下几类:

可以使用new运算符调用构造器创建新的对象,这些构造器的能力是原生的(即无法用纯JavaScript代码实现的),也无法用class/extend来继承的

这些构造器创建的对象多数使用了私有字段,例如Error: [[ErrorData]]Boolean: [[BooleanData]]等,这些字段使得原型继承方法无法正常工作,所以可以认为,这些原生对象都是为了特定能力或性能,而设计出来的“特权对象”

用对象来模拟函数与构造器:函数对象和构造器对象

JavaScript为用来模拟函数与构造器的对象预留了私有字段,并据此规定了抽象的函数对象和构造器对象的概念

函数对象的定义是,具有[[call]]私有字段的对象

构造器对象的定义是,具有[[construct]]私有字段的对象

JavaScript用对象模拟函数的设计代替了一般的编程语言中的函数,它们可以像其他语言的函数一样被调用、传参。任何宿主,只要提供了“具有[[call]]私有字段的对象”,就可以被JavaScript的函数调用的语法锁调用

[[call]]私有字段必须是一个引擎中定义的函数,需要接受this值和调用参数,并且会产生域的切换。

任何对象只需要实现[[call]],它就是函数对象,可以作为函数被调用。如果它能够实现[[construct]],它就是构造器对象,可以作为构造器被调用。

宿主对象和内置对象的表现

对于宿主对象和内置对象来说,他们实现[[call]](作为函数被调用)和[[construct]](作为构造器调用)也不总是一致。比如内置对象Date作为构造器调用时产生新的对象,作为函数时则返回字符串

1
2
3
4
5
typeof Date()
// "string"

typoeof new Date()
// "object"

在浏览器宿主环境中提供的Image构造器,则根本不允许被作为函数调用:

1
2
3
4
5
new Image()
// <img>

Image()
// Uncaught TypeError: Failed to construct 'Image': Please use the 'new' operator, this DOM object constructor cannot be called as a function.

基本类型(StringNumberBoolean)作为构造器时返回对象,作为函数调用时则产生类型转换的效果。

==注意的是,ES6的箭头函数创建的仅仅是函数,而不能作为构造器使用。==

使用function创建的函数的行为

当使用function关键字或者使用Function创建的函数必定同时是函数和构造器,它们执行同一段代码,但是表现出来的行为效果却不相同。

以前遇到过面试题,题目的中心思想就是如何判断一个函数是作为构造函数调用,还是作为普通函数被调用。两种行为的结果是不同的,进行判断的话就需要搞清楚new调用构造函数时发生了什么:

  1. 创建一个新的对象{}
  2. 实现原型链的继承
  3. 将构造函数的作用域赋给新对象(因此this对象就指向了新对象)
  4. 执行构造函数的代码
  5. 返回新对象(构造函数调用默认返回this,但显式的返回值时,如果返回值的是基本类型,则忽略返回值,仍然返回this,如果返回值是引用类型,则直接返回该返回值作为对象的结果)

所以要判断我认为只需要在函数内部判断this是否是该函数的实例即可。

对象的特殊行为

在固有对象和原生对象中,有一些对象的默写行为与正常对象有很大区别,主要是下标运算(即使用中括号或者.来做属性访问)和设置原型继承

主要有:

  • ArrayArraylength属性根据最大的下标自动发生变化
  • StringString的正整数属性访问会去字符串里查找
  • argumentsarguments的非负整数型下标跟对其函数父对象的输入的变量联动
  • Object.prototype:作为所有正常对象的默认原型,不能在设置原型了(这就是语言的规定,Object.prototype.__proto__ === null,没有为什么)
  • 模块的namespace对象:特殊的地方非常多,跟一般对象完全不用了

一个练习

不使用new运算符,极可能找到获得对象的方法

1
2
3
4
5
6
7
8
9
10
11
let o = {};

const o = function(){};

let o = Object.create(Object.prototype);

let o = Object.assign({})

class o {}

let o = JSON.parse('{}')