JS 中关于类型的几个问题

参考: winter 的重学前端


为什么有的编程规范要求用 void 0 代替 undefined?

因为 JavaScript 的代码 undefined 是一个变量,而并非是一个关键字,这是 JavaScript 语言公认的设计失误之一,所以,我们为了避免无意中被篡改,我建议使用 void 0 来获取 undefined 值。(void 运算来把任一一个表达式变成 undefined 值)

字符串有最大长度吗?

String 用于表示文本数据。String 有最大长度是 2^53 - 1,这在一般开发中都是够用的,但是这个所谓最大长度,并不完全是你理解中的字符数。因为 String 的意义并非“字符串”,而是字符串的 UTF16 编码,我们字符串的操作 charAt、charCodeAt、length 等方法针对的都是 UTF16 编码。所以,字符串的最大长度,实际上是受字符串的编码长度影响的。

0.1 + 0.2 不是等于 0.3 么?为什么 JavaScript 里不是这样的?

由于 JavaScript 中浮点数的运算精度导致的,要进行此类比较要使用如下方式:

1
console.log(Math.abs(0.1 + 0.2 - 0.3) <= Number.EPSILON);

ES6 新加入的 Symbol 是个什么东西?

Symbol 是 ES6 中引入的新类型,就像 Number、String、和 Boolean 一样。每个创建的 symbol 都是一个独一无二的值。

为什么给对象添加的方法能用在基本类型上?

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

JS 中的类型转换

参考: winter 的重学前端


字符串到数字

使用 Number() 转换

Number() 支持十进制、二进制、八进制和十六进制,和科学计数法

1
2
3
4
5
6
Number('123'); // 123  	十进制
Number('0b111'); // 7 二进制
Number('0o13'); // 11 八进制
Number('0xc'); // 12 十六进制
Number('2e3'); // 2000 科学计数法
Number('123ab'); // NaN 出现非数字字符

parseInt() 和 Number() 的区别

parseIntparseFloat 并不使用上述转换规则,所以支持的语法跟这里不尽相同。在不传入第二个参数的情况下,parseInt只支持 16 进制前缀“0x”,而且会忽略非数字字符,也不支持科学计数法。多数情况下 Number 是比 parseIntparseFloat更好的选择。

数字到字符串

  • 在较小的范围内,数字到字符串的转换是完全符合你直觉的十进制表示。
  • Number 绝对值较大或者较小时,字符串表示则是使用科学计数法表示。(可能是避免产生的字符串太长)

基本类型装箱转换为对象

每一种基本类型 Number、String、Boolean、Symbol 在对象中都有对应的类。所谓装箱转换,正是把基本类型转换为对应的对象。

怎么显式转换呢?

直接使用 new 对应的类。之后就能使用对应 的方法了。

1
2
3
new Number(123); // Number {123}
new String('abc'); // String {"abc"}
new Boolean(true); // Boolean {true}

使用 . 运算符产生临时对象

1
2
// . 运算符 产生一个临时对象,使得 "123" 能够使用对象的方法
'123'.length; // 3

上面的示例中,. 运算符的作用大致相当于 new String()

1
2
var strObj = new String('123');
strObj.length; // 3

特殊的 Symbol

Symbol 对象不能直接 new 出来。但可以使用内置的 Object 函数,我们可以在 JavaScript 代码中显式调用装箱能力。

1
2
var symbolObject = Object(Symbol('a'));
typeof symbolObject; // "object"

判别装箱转换后的对象对应的基本类型

每一类装箱对象皆有私有的 Class 属性,这些属性可以用 Object.prototype.toString 获取

1
2
3
4
var strObj = new String('abc'); // strObj 是一个对象
var symObj = Object(Symbol('sym')); // symObj 是一个对象
Object.prototype.toString.call(strObj); // "[object String]"
Object.prototype.toString.call(symObj); // "[object Symbol]"

在 JavaScript 中,没有任何方法可以更改私有的 Class 属性,因此Object.prototype.toString 是可以准确识别对象对应的基本类型的方法,它比 instanceof 更加准确。

对象拆箱转换为基本类型

在 JavaScript 标准中,规定了 ToPrimitive 函数,它是对象类型到基本类型的转换(即,拆箱转换)。

对象到 String 和 Number 的转换都遵循“先拆箱再转换”的规则。通过拆箱转换,把对象变成基本类型,再从基本类型转换为对应的 String 或者 Number。

拆箱转换过程

拆箱转换会尝试调用 valueOf 和 toString 来获得拆箱后的基本类型。如果 valueOf 和 toString 都不存在,或者没有返回基本类型,则会产生类型错误 TypeError。默认先调用 valueOf ,如果valueOf 返回的值不对再调用toString,依然不对就会报错。

1
2
3
4
5
6
7
8
9
10
11
var o = {
valueOf: () => {
console.log('valueOf');
return {};
},
toString: () => {
console.log('toString');
return {};
},
};
o * 2; // valueOf // toString // TypeError

覆盖拆箱转换默认行为

通过显式指定对象的 toPrimitive Symbol 来覆盖原有的行为。

1
2
3
4
5
o[Symbol.toPrimitive] = () => {
console.log('toPrimitive');
return 'hello';
};
o + ''; // 'hello'

typeof 结果与运行时类型对比

typeof结果与运行时类型对比.png

永远不用隐式转换

规则复杂,不要浪费宝贵的精力去记忆这种东西。需要转换的时候,应该用显式转换,不要省这点功夫。
真要用,用的时候再上网查

JS中关于类型的细节

参考: winter 的重学前端


JS 中有哪些类型?

JavaScript 语言的每一个值都属于某一种数据类型。JavaScript 语言规定了 7 种语言类型。语言类型广泛用于变量、函数参数、表达式、函数返回值等场合。根据最新的语言标准,这 7 种语言类型是:

  1. Undefined
  2. Null
  3. Boolean
  4. String
  5. Number
  6. Symbol
  7. Object

Undefined 和 Null

  • Undefined 类型只有一个值,就是 undefined,JS 中任何变量在赋值前,它的类型是 Undefined,值为 undefined。一般可以用 JS 中的全局变量 undefined(就是名为 undefined 的这个变量)来表达这个值,或者 void 运算来把任一一个表达式变成 undefined 值。

  • Null 类型也只有一个值,就是 null,它的语义表示空值,与 undefined不同,null 是 JavaScript 关键字,所以在任何代码中,都可以放心用 null 关键字来获取 null 值。

undefined 和 null 的区别

undefined 跟 null 有一定的表意差别,null 表示的是:定义了但是为空。所以,在实际编程时,我们一般不会把变量赋值为 undefined,这样可以保证所有值为 undefined 的变量,都是从未赋值的自然状态。

Boolean

Boolean 类型有两个值,truefalse,它用于表示逻辑意义上的真和假,同样有关键字 true 和 false 来表示两个值。

String

String 用于表示文本数据。String 有最大长度是 2^53 - 1。

String 是 UTF16 编码

String 的意义并非“字符串”,而是字符串的 UTF16 编码,我们字符串的操作 charAt、charCodeAt、length 等方法针对的都是 UTF16 编码。所以,字符串的最大长度,实际上是受字符串的编码长度影响的。

String 可以作为值

JavaScript 中的字符串是永远无法变更的,一旦字符串构造出来,无法用任何方式改变字符串的内容,所以字符串具有值类型的特征。

Number

JavaScript 中的 Number 类型有 2^64-2^53+3 个值。

Number 里的特殊值

  • NaN,占用了 9007199254740990 个特殊值来表示 NaN,这原本是符合 IEEE 规则的数字;
  • Infinity,无穷大;
  • -Infinity,负无穷大。

+0-0 不同

  • 通过检测 1/x 是 Infinity 还是 -Infinity 去区分 x 是 +0 还是 -0 。

浮点数运算的精度问题

1
console.log(0.1 + 0.2 == 0.3); // false

JS 中浮点数运算的精度问题导致等式左右的结果并不是严格相等,而是相差了个微小的值。

正确的比较方法是使用 JavaScript 提供的最小精度值,检查等式左右两边差的绝对值是否小于最小精度

1
console.log(Math.abs(0.1 + 0.2 - 0.3) <= Number.EPSILON); // true

Symbol

Symbol 是个啥?

Symbol 是 ES6 中引入的新类型,就像 Number、String、和 Boolean 一样。

如何创建 Symbol 类型

与别的原始类型不同,Symbols 没有字面量语法(例如,String 有 '')—— 创建 Symbol 的唯一方式是使用全局的 Symbol 函数。记住每个被创建出来的symbol 值都是独一无二的。

1
2
3
4
5
// 可以不用加参数,加了参数以后,就等于为它们加上了描述,输出的时候我们就能够分清,到底是哪一个值。
let s = Symbol('test');
typeof s; // "symbol"
// 每个被 Symbol() 创建的 symbol 都是独一无二的
Symbol('123') === Symbol('123'); // false

Symbol 是可以用来干嘛?

  1. 作为对象的属性名,可以保证这个属性名永远不会冲突(不能用. 运算符)
  2. 给予开发者在 API 中为对象改写默认行为的能力
    • 操作 ES6 中对象内置的 Symbols 属性,例如 Symbol.iterator 等这些内置的 Symbol 可以 JavaScript 内部行为

Object

Object 是 JavaScript 中最复杂的类型,也是 JavaScript 的核心机制之一。Object 表示对象的意思,它是一切有形和无形物体的总称。

JS 中的 Object ?

在 JavaScript 中,对象的定义是“属性的集合”。属性分为数据属性和访问器属性,二者都是 key-value 结构,key 可以是字符串或者 Symbol 类型。

JS 中的类与对象

JavaScript 中的“类”仅仅是运行时对象的一个私有属性,而 JavaScript 中是无法自定义类型的。

基本类型与 Object 的联系

JavaScript 中的几个基本类型,都在对象类型中有一个“亲戚”。它们是:

  • Number
  • String
  • Boolean
  • Symbol

Number、String 和 Boolean,三个构造器是两用的,当跟 new 搭配时,它们产生对象,当直接调用时,它们表示强制类型转换。

Symbol 函数比较特殊,直接用 new 调用它会抛出错误,但它仍然是 Symbol 对象的构造器。

对象为基本类型提供的便利

日常代码可以把对象的方法在基本类型上使用,例如:

1
console.log('abc'.length); // 3

原因在于**.** 运算符提供了装箱操作,它会根据基础类型构造一个临时对象,使得我们能在基础类型上调用对应对象的方法。但是 3new Number(3) 是完全不同的值,它们一个是 Number 类型, 一个是对象类型。

图解

JS运行时类型.png

常用语义标签快速了解

aside - 侧栏

aside 表示跟文章主体不那么相关的部分,它可能包含导航、广告等工具性质的内容。


article - 独立主体

页面中具有明确独立性的部分。


header,footer - 头部/底部

  • header,如其名,通常出现在前部,表示导航或者介绍性的内容。

  • footer,通常出现在尾部,包含一些作者信息、相关链接、版权信息等。


section - 语义化 div

section 元素代表文档中的“节”或“段”,“段”可以是指一片文章里按照主题的分段;“节”可以是指一个页面里的分组。section 通常还带标题,虽然 html5 中 section 会自动给标题 h1-h6 降级,但是最好手动给他们降级。


hgroup, h1, h2 - 标题组

hgroup 是标题组,h1 是一级标题,h2 是二级标题,出现有主副标题情况时,用 hgroup 包裹住

1
2
3
4
<hgroup>
<h1>主标题</h1>
<h2>副标题</h2>
</hgroup>

abbr - 缩写

用来包裹缩写的内容。

1
<abbr title="World Wide Web">WWW</abbr>

hr - 转折

样式表现为一根横线,但表示的是故事走向的转变或者话题的转变,如果需要纯视觉效果的横线不应用此标签,而只用 CSS 去实现。


p - 段落

一般用来表示段落,也可以用 class = "note" 的方式表示 HTML 中没有相关语义标签时的替代。


strong, em - 强调

strong 表示包裹的内容很重要, em 表示重音,防止歧义。


blockquote, q, cite - 引用

blockquote 表示段落级引述内容,q 表示行内的引述内容,cite 表示引述的作品名。


time - 时间

1
<time datetime="2019-7-30">30 July 2019</time>

figure, figcaption - 独立插入

figure 表示一段富文本,可以是一个文章插图、一段代码、一个表格,通常搭配 figcaption 来表述这段富文本的描述/标题,当然,一个 figure 下只能有一个 figcaption,也可以没有


dfn - 定义

用来包裹被定义的名词

1
<dfn>苹果</dfn>是一种水果。

nav 表示网站的导航,但不一定所有的导航都需要用 nav 来实现,建议仅用来实现比较重要的导航,例如网页页脚的链接列表,直接 footer 即可。另外,每个页面可以有多个 nav。ul 表示无序列表, ol 表示有序列表。ul,ol 多数出现正在行文中间,它的上文多数在提示:要列举某些项。不要给所有并列关系,递进关系都加上 ul, ol 标签。


pre, samp, code - 预设置

pre 标签,表示这部分内容是预先排版过的,不需要浏览器进行排版。samp 标签表示一段计算机程序的示例输出。code 标签表示一段代码。

1
2
3
4
5
6
<pre>
<samp>
GET /home.html HTTP/1.1
Host: www.example.org
</samp>
</pre>
1
2
3
4
5
6
7
8
9
10
11
12
<pre>
<code>
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;Example.org – The World Wide Web&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;p&gt;The World Wide Web, abbreviated as WWW and commonly known ...&lt;/p&gt;
&lt;/body&gt;
&lt;/html&gt;
</code>
</pre>

不常用语义标签

不常用语义标签.jpg

快速理解 Web 语义化

参考: winter 的重学前端


语义类标签是什么?

语义是我们说话表达的意思,多数的语义实际上都是由文字来承载的。语义类标签则是纯文字的补充,比如标题、自然段、章节、列表,这些内容都是纯文字无法表达的,我们需要依靠语义标签代为表达。

为什么要用语义?

  • 正确的标签做正确的事情
  • 页面内容结构化
  • 语义类标签增强了可读性,无 CSS 样子时也容易阅读,便于阅读维护和理解,对开发者友好
  • 便于浏览器、搜索引擎解析。 利于爬虫标记、利于 SEO
  • 语义类可以支持读屏软件,根据文章可以自动生成目录

什么情况下不需要使用语义?

在产品里,HTML 用于描述软件界面多过富文本时,因为软件界面里的东西,实际上几乎是没有语义的。比如说,我们做了一个购物车功能,购物车这个按钮,我们一定要用Button吗?实际上我觉得没必要,因为这个场景里面,跟表单中的Button,其实已经相差很远了,所以,在任何软件界面的场景中,可以直接使用 div 和 span。

语义化的三个常见使用场景

自然语言表达能力的补充

作为自然语言和纯文本的补充,用来表达一定的结构或者消除歧义


比如使用 em 标签去消除歧义

1
我今天吃了一个苹果

我们看看这句话,看上去它很清楚,但是实际上,这句话放到不同上下文中,可能表达完全不同的意思。

1
昨天我吃了一个香蕉。 今天我吃了一个苹果。

再比如:

1
昨天我吃了两个苹果。 今天我吃了一个苹果。

在读这两段里面的“今天我吃了一个苹果”,发现读音不自觉地发生了变化。

实际上,不仅仅是读音,这里的意思也发生了变化。前一段中,表示我今天吃的是苹果,而不是别的什么东西,后一段中,则表示我今天只吃了一个苹果,没有多吃。

当没有上下文时,如何消除歧义呢?这就要用到我们的em标签了。em 表示重音:

1
今天我吃了一个<em>苹果</em>。 今天我吃了<em>一个</em>苹果。

通过em标签,我们可以消除这样的歧义。

一些文章常常会拿emstrong做对比,实际上,我们只要理解了em的真正意思,它和strong可谓天差地别,并没有任何混淆的可能。

文章标题摘要

一篇文档会有一个树形的目录结构,它由各个级别的标题组成。这个树形结构可能不会跟 HTML 元素的嵌套关系一致。


h1-h6 是最基本的标题,它们表示了文章中不同层级的标题。有些时候,我们会有副标题,为了避免副标题产生额外的一个层级,我们使用 hgroup 标签。

我们来看下有/无 hgroup 的对比:

1
2
3
4
<h1>JavaScript对象</h1>
<h2>我们需要模拟类吗?</h2>
<p>balah balah</p>
......

此段生成以下标题结构:

  • JavaScript 对象
    • 我们需要模拟类吗?
1
2
3
4
5
6
<hgroup>
<h1>JavaScript对象</h1>
<h2>我们需要模拟类吗?</h2>
</hgroup>
<p>balah balah</p>
......

这一段生成以下标题结构:

  • JavaScript 对象——我们需要模拟类吗?

我们通过两个效果的对比就可以知道,在 hgroup 中的 h1-h6 被视为同一标题的不同组成部分。

从 HTML 5 开始,我们有了 section 标签,这个标签可不仅仅是一个“有语义的 div”,它会改变 h1-h6 的语义。section 的嵌套会使得其中的 h1-h6 下降一级,因此,在 HTML5 以后,我们只需要 section 和 h1 就足以形成文档的树形结构:

适合机器阅读的整体结构

随着越来越多的浏览器推出“阅读模式”,以及各种非浏览器终端的出现,语义化的 HTML 适合机器阅读的特性变得越来越重要。

应用了语义化结构的页面,可以明确地提示出页面信息的主次关系,它能让浏览器很好地支持“阅读视图功能”,还可以让搜索引擎的命中率提升,同时,它也对视障用户的读屏软件更友好。

我们正确使用整体结构类的语义标签,可以让页面对机器更友好。比如,这里一个典型的 body 类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<body>
<header>
<nav>...</nav>
</header>
<aside>
<nav>...</nav>
</aside>
<article>
<header>...</header>
<section>...</section>
<section>...</section>
<section>...</section>
<footer>...</footer>
</article>
<article>...</article>
<article>...</article>
<footer>
<address></address>
</footer>
</body>

在 body 下面,有一个 header,header 里面是一个 nav,跟 header 同级的有一个 aside,aside 里面也有一个 nav。接下来是多个独立的文章,也就是一个一个的 article。每一个 article 里面都有自己的 header、section、footer。section 里面可能还有嵌套,但是我们就不管了,最后是一个 footer,这个 footer 里面可能有 address 这样的内容。

在这个结构里,我们看到了一些新标签,我也来逐个介绍一下。

  • header,如其名,通常出现在前部,表示导航或者介绍性的内容。
  • footer,通常出现在尾部,包含一些作者信息、相关链接、版权信息等。

header 和 footer 一般都是放在 article 或者 body 的直接子元素,但是标准中并没有明确规定,footer 也可以和 aside,nav,section 相关联(header 不存在关联问题)。

  • aside 表示跟文章主体不那么相关的部分,它可能包含导航、广告等工具性质的内容。

aside 很容易被理解为侧边栏,实际上二者是包含关系,侧边栏是 aside,aside 不一定是侧边栏。

aside 和 header 中都可能出现导航(nav 标签),二者的区别是,header 中的导航多数是到文章自己的目录,而 aside 中的导航多数是到关联页面或者是整站地图。

最后 footer 中包含 address,这是个非常容易被误用的标签。address 并非像 date 一样,表示一个给机器阅读的地址,而是表示“文章(作者)的联系方式”,address 明确地只关联到 article 和 body。

总结

本篇中我们介绍了一些基本原则和 HTML 文档的整体结构,从整体上了解了 HTML 语义。
分一些场景来看语义,把它用在合适的场景下,可以获得额外的效果。

快速弄懂 BFC

参考

BFC 是什么

css 2.1 规范。BFC(Block formatting context)直译为”块级格式化上下文”。它是一个独立的渲染区域,只有 Block-level box 参与, 它规定了内部的 Block-level Box 如何布局,并且与这个区域外部毫不相干。

目的是: 形成一个完全独立的空间,让空间中的子元素不会影响到外面的布局。

如何触发 BFC

为元素设置一些 CSS 属性,就能触发 BFC ,即生成一个完全独立的空间。、

最常用触发规则:

  1. float不为 none;
  2. positiion 不为 staticrelative;
  3. overflowauto, scrollhidden;
  4. display 的值为 table-cellinline-block.

BFC 可以解决什么问题

1. 解决浮动元素令父元素高度坍塌的问题

正常情况:

1
2
3
4
5
6
<div class="father">
<div class="son left">元素A</div>
<div class="son right">元素B</div>
<div class="son left">元素C</div>
<div class="son right">元素D</div>
</div>
1
2
3
4
5
6
7
.father {
background: #333;
}
.son {
background: #ccc;
outline: 1px dashed red;
}

正常情况布局
为子元素添加浮动:

1
2
3
4
5
6
.left {
float: left;
}
.right {
float: right;
}

子元素浮动时

此时可以看到,father 元素已经不可见了(高度坍缩为 0 了)。

为 父元素 设置 BFC :

1
2
3
4
5
.father {
background: #333;
// 可根据具体情况设置适合的 css 属性来设置 BFC
overflow: hidden;
}

设置BFC后

可以看到,再设置了 BFC 后,father 元素再次可见,并且完全包裹住了内部浮动的子元素。

2. 解决两栏自适应布局问题(利用 float 实现时)

未设置 BFC 时:

1
2
3
4
<body>
<div class="left"></div>
<div class="main">...省略大量字符</div>
</body>
1
2
3
4
5
6
7
8
9
10
.left {
float: left;
width: 100px;
height: 100px;
background: lightgreen;
}
.main {
height: 140px;
background: lightblue;
}

未设置 BFC 时

可以看到 main 元素的内容溢出到了左侧,要解决这个问题我们就可以为 main 元素设置 BFC

1
2
3
4
5
6
.main {
height: 140px;
background: lightblue;
// 可根据具体情况设置适合的 css 属性来设置 BFC
overflow: hidden;
}

设置 BFC 后

此时main 元素就是一个独立的空间,不会和浮动的 left 重合了。

3. 解决外边距垂直方向重合的问题

一般情况下,兄弟元素的外边距在垂直方向会取两者的最大值而不是取和

1
2
3
4
<div class="father">
<div class="son1"></div>
<div class="son2"></div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
.father {
outline: 1px solid red;
}
.son1 {
height: 20px;
margin-bottom: 20px;
background: lightgreen;
}
.son2 {
height: 30px;
margin-top: 30px;
background: lightblue;
}

外边距重合

可以看到,虽然咱们分别给 son1son2 都设置了 margin 但是,他们之间的 margin 却只有 30px,要解决这个问题就可以通过设置 BFC 来实现。

1
2
3
4
5
6
<div class="father">
<div class="son1"></div>
<div class="bfc">
<div class="son2"></div>
</div>
</div>
1
2
3
4
.bfc {
// 可根据具体情况设置适合的 css 属性来设置 BFC
overflow: hidden;
}

外边距不重合

可以看到,此时 son1son2 元素的外边距都发挥了作用,不再重合在一起了。

注意

触发 BFC 的属性很多,但是选择要慎重,要考虑到实际的情况,避免影响到其他元素的布局,比如说,用 float 触发 BFC 的时候就要注意 float 可能对布局产生的影响所以一定要想清楚,用什么方式去处触发他。

这些问题的其他解决方案

要解决上述几个问题,其实不是只有设置 BFC 这一个解决方案。例如:

  1. 解决浮动元素令父元素高度坍塌的问题

    • 让父元素也浮动起来,父元素和子元素一起脱离文档流。(代码量少,可能影响后面元素的排列)
    • 给父元素添加一个固定高度。(只适用于已知子元素高度的情况,不灵活,难以维护)
    • 在浮动的子元素后添加空元素,设置(clear: both)来清除浮动。(会增加无意义便签,不利于维护)
    • 为浮动的最后一个子元素设置伪元素(:after: {clear: both})。(同上)
  2. 解决两栏自适应布局问题

    • 左边左浮动,右边设置左侧宽度的 margin-left
    • 左边绝对定位,右边设置左侧宽度的 margin-left
    • 左边绝对定位,右边同样绝对定位,left设为左侧宽度, width 设置 100%
    • 使用 flex 布局 等。
  3. 解决外边距垂直方向重合的问题

    • 使用 paddind 代替 margin 。(之后需要设置 padding 的话改起来麻烦)

通过样例来理解 MVC 模式

参考: 自制前端框架之 MVC
参考: MVC,MVP 和 MVVM 的图示

如何设计一个程序的结构,这是一门专门的学问,叫做”架构模式”(architectural pattern),属于编程的方法论。MVC 模式就是架构模式的一种,在 UI 编程领域大有大量使用 MVC 模式的开发框架 (Django 等后端框架),使得开发者能够借助该模式,构建出更易于扩展和维护的应用程序。

MVC 简介

大体上可将 MVC 模式的结构分为三层,即 Model(模型)、View(视图)和 Controller(控制)

  • Model: 专注数据的存取,对基础数据对象的封装。
  • Controller: 处理具体业务逻辑,即根据用户从”视图层”输入的指令,从 Model 中存取数据,并渲染到 View 中。
  • View : 负责界面视图,供用户查看和操作,可理解为【输入数据,输出界面】的模块,在其中通常不涉及的业务逻辑。

这三层是紧密联系在一起的,但又是互相独立的,每一层内部的变化不影响其他层。每一层都对外提供接口(Interface),供其他层调用。这样一来,软件就可以实现模块化,修改外观或者变更数据都不用修改其他层,大大方便了维护和升级。

需要注意的是,MVC 仅是一种模式理念,而非具体的规范。因此,根据 MVC 的理念所设计出的框架,在实现和使用上可能存在着较大的区别。

咱们的目标

常见的后端框架所封装的功能,不外乎对数据的增查改删与渲染。在前端,我们以一个非常简单的 Todo App 作为示例,来实际看看 MVC 模式到底是怎样工作的。

  • Model 模块实现 Todo 这一数据模型的存取。
  • View 模块实现将 Todo 数据模型渲染到页面。
  • Controller 模块实现对 Todo 数据的新增、编辑、删除等操作。

编写代码

Model 模块

按照 MVC 模式,Model 模块的主要工作是存取数据,并且在数据变化时将新数据传给 View 模块。Model 模块的核心是订阅-发布者模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Model {
// 在构造器中实例化数据与订阅者
// 本例中的数据就是 一个个的 todo
constructor() {
// 数据格式 [{id: 1, value: '123'}]
this.todo = [];
this.todo;
// 【初始化订阅者】
this.subscribers = [];
}

// 利用 ES6 class 语法定义模型实例的 getter
// 从而在调用 model.data 时返回正确的 Todo 数据
get data() {
return this.todo;
}

// 利用 ES6 class 语法定义模型实例的 setter
// 从而在执行形如 modle.data = newData 的赋值时
// 能够通知订阅了 Model 的模块进行相应更新 【数据更新时,触发订阅回调】
set data(data) {
this.todo = data;
this.publish(this.todo);
}

// 由 Model 实例调用的发布方法
// 在 Model 中的 setter 更新时,将新数据传入该方法
// 由该方法将新数据推送到每个订阅者提供的回调中
// 在 本项目中,订阅者为 Controller 的 render 方法 【触发所有订阅】
publish(data) {
// 此处的订阅者是 业务中的函数
this.subscribers.forEach(render => render(data));
}
}

在示例中可以发现,所谓的发布 - 订阅模式,其思路和实现均非常简单:

  1. 区分出【发布者】和【订阅者】的概念。本例中 Model 为发布者,Controller 为订阅者。
  2. 在发布者中维护【我有哪些订阅者】信息的数组,每个元素为一个订阅者提供的回调。
  3. 发布者数据更新时,依次触发所有订阅者的回调。

不过,Model 中的代码仅实现了【初始化发布者】与【触发所有订阅】,【数据更新时,触发订阅回调】的功能,并不是一个完整的发布 - 订阅模式。在完整的模式实现中,其余代码包括:

  1. 【订阅者订阅发布者】机制的实现,其代码位置为 Controller 中的最后一行 this.model.subscribers.push(this.render),在此将 render 方法作为订阅者回调,提供给了发布者。
  2. 【订阅者提供的订阅方法】的实现,在此即为 Controller 中提供的 this.render 方法。

Controller 模块

上文中已经明确,Controller 模块需要实现的功能为:

  • 与 Model / View 实例的绑定。
  • 对点击事件、DOM 选择等底层 API 的封装。
  • 用于渲染数据的 Render 方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class Controller {
constructor(conf) {
// 根据实例化参数,定义 Controller 基础配置
// 包括 DOM 容器、Model / View 实例及 onClick 事件等
this.el = document.querySelector(conf.el);
this.model = conf.model;
this.view = conf.view;
// 为 容器 dom 设置事件
this.bindEvent(this.el);
// 给 render 函数绑定 this
this.render = this.render.bind(this);
// 在 Model 更新时执行 controller 的 render 方法
this.model.subscribers.push(this.render);
}

// 根据点击 btn 的 class 属性绑定不同的事件回调
bindEvent() {
this.el.addEventListener('click', event => {
let el = event.target;
let id = el.dataset.id;
if (el.classList.contains('todo-delete')) {
deleteTodo(id);
} else if (el.classList.contains('todo-update')) {
updateTodo(el, id);
} else if (el.classList.contains('todo-add')) {
addTodo(el);
}
});

// 点击 add 时,把新的 todo 添加到 model 中
const addTodo = el => {
let input = el.parentElement.querySelector('.input-add');
let value = input.value;
if (value !== '') {
let data = this.model.data;
data.push({
id: data.length,
value: value,
});
this.model.data = data;
}
};

// 点击 delete 时,把对应 todo 从 model 中删除
const deleteTodo = id => {
this.model.data = this.model.data.filter(todo => {
return id !== String(todo.id);
});
};

// 点击 update 时,把对应 todo 在 model 中更新
const updateTodo = (el, id) => {
this.model.data = this.model.data.map(todo => {
return {
id: todo.id,
value: setValue(id, todo),
};
});
};

// 辅助 更新函数
const setValue = (id, todo) => {
if (id === String(todo.id)) {
let updateInput = document.querySelector(`input[data-id="${todo.id}"]`);
return updateInput.value;
} else {
return todo.value;
}
};
}

// 全量重置 DOM 的 naive render 实现
render() {
// 由于 view 是纯函数,故而直接对其传入 Model 数据
// 将输出的 HTML 模板作为 Controller DOM 内的新状态
this.el.innerHTML = this.view(this.model.todo);
}
}

可以看到 Controller 模块在点击事件触发时,没有直接修改 dom, 只是修改了 model 中的数据, dom 视图的改变是在 model 中数据变化时自动 render 的。

View 模块

如前文所述, View 模块实质上就是一个在 Model 中数据变更时,由 Controller 在 render 方法中执行的一个纯函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function view(todos) {
const todosList = todos
.map(
todo => `
<div>
<span data-id="${todo.id}">
${todo.value}
</span>
<button data-id="${todo.id}" class="todo-delete">
删除
</button>
<span>
<input data-id="${todo.id}"/>
<button data-id="${todo.id}" class="todo-update">
Update
</button>
</span>
</div>
`
)
.join('');

return `
<main>
<input class="input-add"/>
<button class="todo-add">Add</button>
<div>${todosList}</div>
</main>
`;
}

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script src="./mvc.js"></script>
<script>
const model = new Model();
let conf = {
model,
view,
el: '#app',
};
const controller = new Controller(conf);
controller.render();
</script>
</body>
</html>

总结

在实现 MVC 模式的 todo app 的过程中,MVC 模式的特性得到了体现,【Model, View】模块直接只对外提供接口, 【Controller】模块则将两者连接了起来,而 ES6 所提供的 class 高级特性则大大简化这些特性的实现复杂度(setter getter 等)。

最后的最后,咱们再梳理下流程:

点击按钮 -> 触发 controller 里的事件 -> 更改 model 数据 -> 触发 render 函数 -> 更新视图

js 面向对象编程(一):封装

参考:阮一峰老师的博客

Javascript 是一种基于对象(object-based)的语言,我们遇到的所有东西几乎都是对象。但是,它又不是一种真正的面向对象编程(OOP)语言,因为它的语法中没有class(类)的概念。那么,如果我们要把”属性”(property)和”方法”(method),封装成一个对象,甚至要从原型对象生成一个实例对象,我们应该怎么做呢?

1. 工厂模式(改进的原始模式)

这种模式抽象了创建具体对象的过程,用函数来封装以特定接口创建对象的细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const creatPerson = function (name, age) {
return {
name: name,
age: age,
sayName: function () {
console.log(this.name);
},
};
};
// 生成实例对象的过程其实就是调用这个函数
const xcmy = creatPerson('小明', 23);
const xchw = creatPerson('小花', 22);
xcmy.sayName(); // 小明
console.log(xchw.age); // 22

弊端

工厂模式虽然解决了创建多个相似对象的问题,但却没有解决对象识别的问题(即怎样知道一个对象的类型),也不能反映实例之间的联系


构造函数模式(构造器模式)

为了解决从原型对象生成实例的问题,Javascript 提供了一个构造函数(Constructor)模式。所谓”构造函数”,其实就是一个普通函数,但是内部使用了this变量。对构造函数使用new运算符,就能生成实例,并且this变量会绑定在实例对象上。重写 Person

1
2
3
4
5
6
7
8
9
10
11
12
13
const Person = function (name, age) {
this.name = name;
this.age = age;
this.sayName = function () {
console.log(this.name);
};
};
// 生成实例就是 new Person,
// 现在的 Person 函数叫做构造函数
const xcmy = new Person('小明', 23);
const xchw = new Person('小花', 22);
xcmy.sayName(); // 小明
console.log(xchw.age); // 22

这时xcmyxchw会自动含有一个constructor属性,指向它们的构造函数。

1
2
console.log(xcmy.constructor == Person); //true
console.log(xchw.constructor == Person); //true

创建自定义的构造函数意味着将来可以将它的实例标识为一种特定的类型,而这正是构造函数模式胜过工厂模式的地方。
Javascript 还提供了一个instanceof运算符,验证原型对象与实例对象之间的关系。

1
2
console.log(xcmy instanceof Person); //true
console.log(xchw instanceof Person); //true

使用 new 创建实例对象发生了什么?

要创建 Person 的新实例,必须使用 new 操作符。以这种方式调用构造函数实际上会经历以下 4 个步骤:

  1. 创建一个新对象;
  2. 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象);
  3. 执行构造函数中的代码(为这个新对象添加属性);
  4. 返回新对象。

创建自定义的构造函数意味着将来可以将它的实例标识为一种特定的类型;而这正是构造函数模式胜过工厂模式的地方。

弊端

每个用 new 创建出来的实例对象都拥有同样的属性和方法,但是方法(method) 对于每个实例都是一模一样的内容,每次生成一个实例,都必须重复同样的内容,多占用一些内存。这样既不环保,也缺乏效率

原型模式(Prototype 模式)

Javascript 规定,每一个构造函数都有一个prototype属性,指向另一个对象。这个对象的所有属性和方法,都会被构造函数的实例继承。这意味着,我们可以把那些不变的属性和方法,直接定义在prototype对象上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const Person = function (name, age) {
this.name = name;
this.age = age;
};
Person.prototype.sayName = function () {
console.log(this.name);
};
// 生成实例就是 new Person,
// 现在的 Person 函数叫做构造函数
const xcmy = new Person('小明', 23);
const xchw = new Person('小花', 22);
xcmy.sayName(); // 小明
console.log(xchw.age); // 22
console.log(xcmy.sayName === xchw.sayName); // true

这时所有实例的sayName()方法,其实都是同一个内存地址,指向 prototype 对象,因此就提高了运行效率。

Prototype 模式的验证方法

为了配合 prototype 属性,Javascript 定义了一些辅助方法,帮助我们使用它。

isPrototypeOf()

这个方法用来判断,某个proptotype对象和某个实例之间的关系。

1
2
console.log(Person.prototype.isPrototypeOf(xcmy)); //true
console.log(Person.prototype.isPrototypeOf(xchw)); //true

hasOwnProperty()

每个实例对象都有一个hasOwnProperty()方法,用来判断某一个属性到底是本地属性,还是继承自prototype对象的属性。

1
2
console.log(xcmy.hasOwnProperty('name')); // true
console.log(xcmy.hasOwnProperty('sayName')); // false

in 运算符

in运算符可以用来判断,某个实例是否含有某个属性,不管是不是本地属性。

1
2
console.log('name' in xcmy); // true
console.log('sayName' in xcmy); // true

in运算符还可以用来遍历某个对象的所有属性。

1
2
3
for (let prop in xcmy) {
console.log('xcmy[' + prop + ']=' + xcmy[prop]);
}

js 面向对象编程(二):构造函数的继承

组合继承

原理:使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 父类
const Person = function (name, age) {
this.name = name;
this.age = age;
};
Person.prototype.sayName = function () {
console.log(this.name);
};
// 子类
const Male = function (name, age) {
// 继承属性
Person.call(this, name, age);
this.gender = 'male';
};
// 继承方法
Male.prototype = new Person();
// 把新的原型中的 constructor 指回自身
Male.prototype.constructor = Male;

// test
const p1 = new Male('whh', 23);
console.log(p1);
// Male {name: "whh", age: 23, gender: "male"}

图解

弊端:调用了两次父类的构造函数,导致原型中产生了无效的属性。

寄生组合式继承

原理:通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。主要就是用一个空的构造函数,来当做桥梁,并且把其原型对象指向父构造函数的原型对象,并且实例化一个 temp,temp 会沿着这个原型链,去找到父构造函数的原型对象。举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 父类
const Person = function (name, age) {
this.name = name;
this.age = age;
};
Person.prototype.sayName = function () {
console.log(this.name);
};
// 子类
const Male = function (name, age) {
// 继承属性
Person.call(this, name, age);
this.gender = 'male';
};
// 寄生式组合继承
const extend = function (subType, superType) {
const Temp = function () {};
// 把Temp构造函数的原型对象指向superType的原型对象
Temp.prototype = superType.prototype;
// 用构造函数Temp实例化一个实例temp
let temp = new Temp();
// 把子构造函数的原型对象指向temp
subType.prototype = temp;
// 把temp的constructor指向subType
temp.constructor = subType;
};
// 使用
extend(Male, Person);

// test
const p1 = new Male('whh', 23);
console.log(p1);
// Male {name: "whh", age: 23, gender: "male"}

图解

这个例子的高效率体现在它只调用了一次 SuperType 构造函数,并且因此避免了在 SubType.prototype 上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用instanceof isPrototypeOf()

目前程序猿认为解决继承问题最好的方案

其他继承方式(不完美的)

参考: 掘金地址

原型式继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 原型式继承
function createObjWithObj(obj){ // * 传入一个原型对象
function Temp(){}
Temp.prototype = obj
let o = new Temp()
return o
}

// * 把Person的原型对象当做temp的原型对象
let temp = createObjWithObj(Person.prototype)

// * 也可以使用Object.create实现
// * 把Person的原型对象当做temp2的原型对象
let temp2 = Object.create(Person.prototype)

寄生式继承

1
2
3
4
5
6
7
8
9
// 寄生式继承
// 我们在原型式的基础上,希望给这个对象新增一些属性方法
// 那么我们在原型式的基础上扩展
function createNewObjWithObj(obj) {
let o = createObjWithObj(obj)
o.name = "whh"
o.age = 23
return o
}

拷贝继承

1
2
3
4
5
6
7
const extend2(Child, Parent) {
let p = Parent.prototype
let c = Child.prototype
for (var i in p) {
       c[i] = p[i]
}
  }

这个函数的作用,就是将父对象的 prototype 对象中的属性,一一拷贝给 Child 对象的 prototype 对象。使用的时候,这样写:

1
2
3
extend2(Male, Person);
let p1 = new Male('whh', 23);
console.log(p1.age); // 23

js 中的原型和原型链

什么是原型?

无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个 prototype 属性,这个属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获得一个 constructor(构造函数)属性,这个属性包含一个指向prototype 属性所在函数的指针。举例:

1
2
3
4
const Person = function (name) {
this.name = name;
};
console.log(Person.prototype.constructor === Person); // true

在上例中,Person.prototype.constructor 指向 Person。而通过这个构造函数,我们还可继续为原型对象添加其他属性和方法。

实例对象与构造函数的关系

1
2
const person1 = new Person('小明');
console.log(person1); // Person {name: "小明"}

上面的 person1 就是实例对象,Person 就被我们叫做 构造函数

构造函数的 prototype 属性与它创建的实例对象的[[prototype]]属性指向的是同一个对象,即 实例对象.__proto__ === 构造函数.prototype,所以:

1
console.log(person1.__proto__ === Person.prototype); // true

原型链

  • 每个对象都拥有一个隐藏的属性[[prototype]],指向它的原型对象,这个属性可以通过Object.getPrototypeOf(obj)obj.__proto__ 来访问。

  • 原型对象同样也有一个隐藏的属性[[prototype]],指向它的原型对象。

  • 通过__proto__ 属性将对象和原型连接起来,就组成了原型链。

原型链的作用

当试图访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及该对象的原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或到达原型链的末尾。
下面的示例来自 MDN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 让我们从一个自身拥有属性a和b的函数里创建一个对象o:
let f = function () {
this.a = 1;
this.b = 2;
};

let o = new f(); // o: {a: 1, b: 2}
// 在f函数的原型上定义属性
f.prototype.b = 3;
f.prototype.c = 4;

/*
不要在f函数的原型上直接定义 f.prototype = {b:3,c:4};这样会直接打破原型链
o.[[Prototype]] 有属性 b 和 c (其实就是o.__proto__或者o.constructor.prototype)
o.[[Prototype]].[[Prototype]] 是 Object.prototype.
最后o.[[Prototype]].[[Prototype]].[[Prototype]]是null
这就是原型链的末尾,即 null,
根据定义,null 没有[[Prototype]].
综上,整个原型链如下:
{a:1, b:2} ---> {b:3, c:4} ---> Object.prototype---> null
*/
console.log(o.a); // 1
//a是o的自身属性吗?是的,该属性的值为1

console.log(o.b); // 2
// b是o的自身属性吗?是的,该属性的值为2
// 原型上也有一个'b'属性,但是它不会被访问到.这种情况称为"属性遮蔽 (property shadowing)"

console.log(o.c); // 4
// c是o的自身属性吗?不是,那看看原型上有没有
// c是o.[[Prototype]]的属性吗?是的,该属性的值为4

console.log(o.d); // undefined
// d是o的自身属性吗?不是,那看看原型上有没有
// d是o.[[Prototype]]的属性吗?不是,那看看它的原型上有没有
// o.[[Prototype]].[[Prototype]] 为 null,停止搜索
// 没有d属性,返回undefined

总结

  • Object 是所有对象的爸爸,所有对象都可以通过 __proto__ 找到它
  • Function 是所有函数的爸爸,所有函数都可以通过 __proto__ 找到它
  • 函数的 prototype 是一个对象
  • 对象的 __proto__ 属性指向原型, __proto__ 将对象和原型连接起来组成了原型链
  • 对象里找不到的属性会到原型里去找