react hooks 初步

React Hooks 的意思是,组件尽量写成纯函数,如果需要外部功能和副作用,就用钩子把外部代码”钩”进来。 React Hooks 就是那些钩子。

react 约定, hook 一律使用 use 前缀命名,便于识别
常用 hook

useState()
useContext()
useEffect()
useReducer()
useState

useState()用于为函数组件引入状态(state)。纯函数不能有状态,所以把状态放在钩子里面。

1
2
3
4
function Button() {
const [count, setCount] = useState(0);
return <button onClick={setCount(count + 1)}>+1</button>;
}

useState()接受初始值,返回一个数组,数组第一个是当前状态,第二个是更新状态的函数。

useContext

useContext()用于组件间共享状态。

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
// 创建一个 context 对象
const ctxDep = {name: '王花花'}
const Ctx = React.creatContext(ctxDep)
// 在组件中使用
function App() {
return (
// 下级组件使用 useContext 获取的值是 ctxDep.name
<Ctx.Provider value={ctxDep.name}>
<Sub />
<Sub2 />
</Ctx.Provider>
)
}
function Sub() {
// useContext 获取的是距离当前组件最近的 <MyContext.Provider> 的 value prop 。
// 此处的就是 ctxDep.name
const name = useContext(Ctx)
return (
<div>name: {name}</div>
)
}
function Sub2() {
const name = useContext(Ctx)
...
}

useEffect

useEffect()用来引入具有副作用的操作,最常见的就是向服务器请求数据。

1
2
3
useEffect(() => {
// Async Action
}, [dependencies]);

useEffect()接受两个参数。第一个是一个函数,操作的代码放在里面(常见的是异步操作)。第二个是一个数组,用于给出 Effect 的依赖项,只要这个数组发生变化,useEffect()就会执行。第二个参数可以省略,这时每次组件渲染时,就会执行 useEffect()。 React 会等待浏览器完成画面渲染之后才会延迟调用 useEffect

useEffect 清除操作

在 useEffect 中返回一个函数去执行清除操作, React 会在组件卸载的时候执行清除操作。React 会在执行当前 effect 之前对上一个 effect 进行清除。

1
2
3
4
5
6
useEffect(() => {
...
return function cleanFun() {
...
}
}, [])

useReducer

useReducers()钩子用来引入 Reducer 功能。升级版 useState

1
const [state, dispatch] = useReducer(reducer, initialState);

useReducer()接受 Reducer 函数和状态的初始值作为参数,返回一个数组。数组的第一个成员是状态的当前值,第二个成员是与其配套的发送 action 的 dispatch 函数。

使用 useReducer()接受的 reducer 形式如(state, action) => newState

惰性初始化

使用 useReducer 的第三个参数来设置初始值

1
2
3
const initialState = 1;
// init 是函数,返回 {count: 1}
const [state, dispatch] = useReducer(reducerCount, initialState, init);

如果 useReducer 的返回值与当前 state 相同,React 将跳过子组件的渲染及副作用的执行。

useMemo

用于性能优化,useMemo 返回一个值。

1
const memoizedValue = useMemo(() => dosomething(a, b), [a, b]);

useMemo 会在渲染期执行,但除了第一次外,只会在依赖项发生改变后才会重新执行。

useCallback

useCallback()返回一个函数, 在依赖值没有发生改变时不触发重新渲染。

1
2
3
4
5
6
// 仅当 count 的值发生改变时,memoCallback 才触发组件重新渲染
function Foo() {
const [count, setCount] = useState(0);
const memoizedHandleClick = useCallback(() => console.log(`点击: ${count}`), [count]);
return <Button onClick={memoizedHandleClick}>Click Me</Button>;
}

useRef

1
2
3
4
5
const refContainer = useRef(initialValue);
return (
// 此时 refContainer.current 指向下面这个 button
<button ref={refContainer}></button>
);

useLayoutEffect

效果 useEffect 相同,但会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。

react 生命周期

挂载阶段

React.js 将组件渲染,并且构造 DOM 元素然后塞入页面的过程称为组件的挂载。

  1. constructor: 所有关于组件自身的状态的初始化工作都会放在这里面去做

  2. static getDerivedStateFromProps(nextProps, prevState): 一个静态方法,所以不能在这个函数里面使用 this,这个函数有两个参数 props 和 state,分别指接收到的新参数和当前的 state 对象,这个函数会返回一个对象用来更新当前的 state 对象,如果不需要更新可以返回 null。

    该函数会在挂载时,接收到新的 props,调用了 setState 和 forceUpdate 时被调用

  3. UNSAFE_componentWillMount:组件挂载开始之前,也就是在组件调用  render  方法之前调用。进行组件的启动工作,例如 Ajax 数据拉取、定时器的启动。

  4. render:React 中最核心的方法,一个组件中必须要有这个方法

    返回的类型有以下几种:

    • 原生的 DOM,如 div
    • React 组件
    • Fragment(片段)
    • Portals(插槽)
    • 字符串和数字,被渲染成 text 节点
    • Boolean 和 null,不会渲染任何东西

    render 函数是纯函数,里面只做一件事,就是返回需要渲染的东西,不应该包含其它的业务逻辑,如数据请求,对于这些业务逻辑请移到 componentDidMount 和 componentDid Update 中

  5. componentDidMount:组件挂载完成以后,也就是 DOM 元素已经插入页面后调用。

更新阶段

除了挂载阶段,还有一种“更新阶段”就是  setState  导致 React.js 重新渲染组件并且把组件的变化应用到 DOM 元素上的过程,这是一个组件的变化过程

  1. UNSAFE_componentWillReceiveProps(nextProps):组件从父组件接收到新的  props  之前调用。
  2. static getDerivedStateFromProps
  3. shouldComponentUpdate(nextProps, nextState):你可以通过这个方法控制组件是否重新渲染。如果返回  false  组件就不会重新渲染。这个生命周期在 React.js 性能优化上非常有用。
  4. UNSAFE_componentWillUpdate():组件开始重新渲染之前调用。
  5. render()
  6. getSnapshotBeforeUpdate(prevProps, prevState):这个方法在 render 之后,componentDidUpdate 之前调用,有两个参数 prevProps 和 prevState,表示之前的属性和之前的 state,这个函数有一个返回值,会作为第三个参数传给 componentDidUpdate,如果你不想要返回值,请返回 null,不写的话控制台会有警告。
  7. componentDidUpdate():组件重新渲染并且把更改变更到真实的 DOM 以后调用。

卸载阶段

componentWillUnmount:组件对应的 DOM 元素从页面中删除之前调用。组件从页面上销毁的时候,有时候需要一些数据的清理,例如定时器的清理,就会放在这里面去做。

参考:

React v16.4.0:你可能并不需要派生状态(Derived State)

对 React v16.4 生命周期的理解

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 的重学前端


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

通过样例来理解 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__ 将对象和原型连接起来组成了原型链
  • 对象里找不到的属性会到原型里去找

正确判断 this 指向

在普通函数中判断

1
2
3
4
5
6
7
8
9
10
11
12
13
const foo = function () {
console.log(this.a);
};
var a = 1;
foo();

const obj = {
a: 2,
foo: foo,
};
obj.foo();

const c = new foo();
  • 对于直接调用 foo 来说,不管 foo 函数被放在了什么地方,this 一定是 window
  • 对于 obj.foo() 来说,我们只需要记住,谁调用了函数,谁就是 this,所以在这个场景下 foo 函数中的 this 就是 obj 对象
  • 对于 new 的方式来说,this 被永远绑定在了 实例对象上面(就是上面的c),不会被任何方式改变 this

在箭头函数中判断

箭头函数其实是没有 this 的,箭头函数中的 this 只取决包裹箭头函数的第一个普通函数的 this。举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
const a = function () {
return function () {
return {
b: () => {
console.log(this.c);
},
c: 2,
};
};
};
var c = 1;

a()().b(); // 结果: 1

上例中,因为包裹箭头函数的第一个普通函数是a,所以此时的 thiswindow。如果把 箭头改为普通函数,结果变为 2,此时规则变为普通函数的 obj.b()规则。另外对箭头函数使用 bind 这类函数是无效的

bind, call, apply 函数改变 this

对于这些函数来说,this取决于第一个参数,如果第一个参数为空,那么就是 Window

对函数进行多次 bind 操作,函数的 this 永远由第一次的 bind 决定。举例:

1
2
3
4
5
let a = {};
let fn = function () {
console.log(this);
};
fn.bind().bind(a)(); // 结果: Window

优先级

以上就是 this 的规则了,但是可能会发生多个规则同时出现的情况,这时候不同的规则之间会根据优先级最高的来决定 this 最终指向哪里。

首先,new 的方式优先级最高,接下来是 bind 这些函数,然后是 obj.foo()这种调用方式,最后是 foo 这种调用方式,同时,箭头函数的 this 一旦被绑定,就不会再被任何方式所改变。

一图胜千言正确判断this

js 中的闭包

什么是闭包?

闭包的定义其实很简单:函数 A 内部有一个函数 B,函数 B 可以访问到函数 A 中的变量,那么函数 B 就是闭包。举例:

1
2
3
4
5
6
7
8
9
10
const a = function () {
let n = 1;
return function () {
console.log(n);
n++;
};
};
const b = a();
b(); // result: 1
b(); // result: 2

在 JS 中,闭包存在的意义就是让我们可以间接访问函数内部的变量。

循环中使用闭包解决 var 定义函数的问题

1
2
3
4
5
6
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
//result;全是 6

因为 setTimeout 是个异步函数,所以会先把循环全部执行完毕,这时候 i就是 6 了,所以会输出一堆 6。

解决办法(三种)

一、使用闭包

1
2
3
4
5
6
7
8
for (var i = 1; i <= 5; i++) {
(function timer(j) {
setTimeout(function () {
console.log(j);
}, j * 1000);
})(i);
}
// result: 1 2 3 4 5

在上面的代码中,我们使用立即执行函数将 i 传入函数内部,这个时候值就被固定在了参数 j 上面不会改变,当下次执行 timer 这个闭包的时候,就可以使用外部函数的变量 j,从而达到目的。(其实相当于创建了 5 个函数作用域, timer 执行的时候就在自己的作用域里去访问 j 的值,而不是统一访问 i)

第二种就是使用 setTimeout 的第三个参数,这个参数会被当成 timer 函数的参数传入。

1
2
3
4
5
6
7
8
9
for (var i = 1; i <= 5; i++) {
setTimeout(
function timer(j) {
console.log(j);
},
i * 1000,
i
);
}

第三种就是使用 let 定义 i 了来解决问题了,这个也是 最为推荐 的方式

1
2
3
4
5
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}