前言
一个react应用是基于状态(state)的组件渲染的框架,即react组件的渲染根据状态的改变而改变。其中,react组件非常重要的属性有三个:
- state:由组件内部管理的组件状态,根据状态的变化来实现不同的组件渲染
- props:从父组件向子组件传递的参数
- refs:组件中事件处理的接口 这三个属性中,state里面存放的是该组件当前的属性,它在程序建立的一开始就存在,除非修改源代码,否则不会主动发生改变;会引起state属性发生改变的有两个途径:
- 从父组件中传递过来,存储在props中的参数发生改变
- 与用户交互的事件发生改变,导致状态的改变 用户交互引起的改变并不会特别复杂,它是一个外部的交互过程,只需要特定的事件引发特定的改变即可。而一个react应用内部的数据获取,或者可以说是状态管理会相对而言复杂得多。随着react应用的状态变得越来越复杂,组件之间的通信也变得越来越复杂,简单的父子组件传值就不能满足我们需求。因此,我们需要一个地方存储和操作一些公共状态。这个时候,大佬们就开始造轮子了,redux就随之出现了。
使用 redux
redux官网对于redux的介绍如下:
Redux是JavaScript应用的状态容器,提供可预测的状态管理。可以让你开发出行为稳定可预测的应用,运行于不同的环境(客户端、服务器、原生应用),并且易于测试。 从官网的这段描述中不难看出,redux是一个非常好用的状态管理工具。它常常于react绑定,因此,要使用redux,除了需要安装redux库以外还需要安装一些附加包
// 安装redux包
npm install --save redux
// 安装react绑定库
npm install --save react-redux
// 安装开发者工具
npm install --save-dev redux-devtools-extension
redux 三大原则
正如前面提到的,随着JavaScript单页面应用越来越复杂,JavaScript需要管理很多很多状态。state引起的model和view的变化也越来越复杂,越来越不可预测。而redux就是来解决这个问题的。redux试图让state变得可预测。要想实现可预测,在redux编写的时候需要添加一些限制,这就是redux的三大原则:
- 单一数据源:
整个应用的状态(state)都存储在同一个状态树(state tree)中,这棵状态树存储在redux提供的唯一的存储空间store中。 - state是只读的:
这条原则并不是说被redux管理的属性是不可变的;而是说在redux中,被管理的属性的读写是分开的。redux状态改变的唯一方式是分发(dispatch)一个action,并且由reducer来对action进行处理。其余时候,state是只读的。 - 使用纯函数来执行修改:
处理state更新的reducer是一个纯函数
redux核心概念
- state 和 state tree:
- state:一个应用中的所有状态都可以称之为状态(state)
- state tree:应用中的状态一般是以树这种数据结构存储在redux中的。因此,叫做状态树
- action
- 定义:action是把数据从应用传到store的有效载荷。它是store数据的唯一来源。一般来说,需要通过
store.dispatch()将action传到store - 关键:每一个action必须要有一个字段
type来表示更改的行为。 - 代码示例:
function addTodo(text) { return { type: ADD_TODO, text } } - 定义:action是把数据从应用传到store的有效载荷。它是store数据的唯一来源。一般来说,需要通过
- reducer
- 定义:reducer指定了应用状态的变化如何响应action并发送到store的,action只是描述了有事情发生,并没有描述应用如何更新state。而reducer就是描述当某件事情发生时,应用如何更新state
- 代码示例:
function todoApp(state = initialState, action) { switch (action.type) { case SET_VISIBILITY_FILTER: return Object.assign({}, state, { visibilityFilter: action.filter }) case ADD_TODO: return Object.assign({}, state, { todos: [ ...state.todos, { text: action.text, completed: false } ] }) default: return state } } - store:
- 定义:store是存储状态并且更新状态和获取状态的对象
- 职责:
- 维持应用的state
- 提供
getState()方法获取状态 - 提供
dispatch(action)方法分发action更新状态 - 通过
subscribe(listener)注册监听器 - 通过
subscribe(listener)返回的函数注销监听器
- 创建store:
通过import { createStore } from 'redux' import todoApp from './reducers' let store = createStore(todoApp)createStore()创建store,创建的同时传入参数reducers
redux 文件设计
- action-type.js:存放action的类型
- actions.js:存放应用中的action
- reducers.js:存放reducer
- store.js:创建store,并且加入相关插件
redux 工作原理
如上图所示,应用的状态都存储在store中。如果我们想更改存储在store中的状态,首先应该分发(dispatch)一个action,action中定义了什么时候该更改状态,也就是说什么时候有事情发生;然后将action交由reducer进行处理,reducer中定义了当发生某种类型的事件的时候状态应该如何发生改变;最后,reducer返回一个新的状态,并且在store中进行状态的更新。这里需要注意的是,reducer中不会更改状态,而是返回一个新的状态。 至于状态是如何更新的,我们并不关心,因为redux库已经帮我们定义好了状态更新的行为了。我们使用的这种只关心做什么而不关心怎么做的编程方式就是声明式编程。
如果想要获取状态,redux库中提供了getState()方法。此外,分发action的行为也是在view中进行的。
redux 实现
redux
这一部分内容,我们以redux的设计思路来组织。要想明白redux的设计思路,首先就应该搞明白redux是为了解决什么问题而产生的。正如前面提到的,redux的出现是为了解决react组件间通信而提出的。通过组件的通信来改变组件状态,只能通过父组件向下传递参数,这样会导致组件的状态变得很混乱和不可预测。既然是需要从其他组件传递参数,那么在应用中应该存在很多公共状态。为了避免这种来回传递参数的情况,我们可以考虑将一些公共状态单独拿出来存到一个地方。redux就为我们提供了一种公共状态的管理方案。
按照这个思路,我们来思考一下如何管理公共状态:最直接的想法是将公共状态取出来放到一个单独的文件当中,然后我们需要的时候进行引用即可。现在,我们创建store.js文件
const state = {
count: 0
}
现在,我们在store文件中存放了一个公共状态count,如果哪个组件需要这个公共状态就可以直接通过import './store.js'引用这个文件。你以为这样的设计就可以了,那你太天真了。这样的设计是有问题的:
- 容易误操作:这个很容易理解。所有的操作者都可以直接操作公共状态,那么公共状态稍微变一下就会牵一发而动全身,而且错误不好排查。因此,我们需要有条件的操作store,不能让使用者直接修改store里的数据。
- 可读性很差:直接操作公共状态会导致状态的不可预测,从而导致与之相关的组件行为的不可预测。在项目交接时,会导致交接人员直接懵逼。因此,我们需要给每个操作起个名字。 所以,我们不能直接把公共状态像这样直接拎出来。我们需要给他加权限。有条件的操作store里的状态。整理一下,公共状态管理器应该满足以下条件:
- 公共状态可以被全局访问到
- 公共状态不能被直接修改:这一点也就是说状态修改的途径必须是唯一的,而且还得给操作起个名字 这两点就要求我们必须把状态的获取(getter)和状态的更新(setter)分开,而且状态发生改变的时候还得进行广播。这几个功能都要通过一个函数来实现,而且还得有能被全局访问到并且不能被直接更改的状态。仔细思考一下,能够满足这两点的就只能是闭包了。代码如下:
export const createStore = () => {
let currentState = {} // 公共状态
function getState() {} // getter
function dispatch() {} // setter
function subScribe() {} // 发布订阅
return { getState, dispatch, subscribe }
}
接下来,我们就依次来实现这三个函数
- getState():
getState()的实现很简单,然会当前状态即可
export const createStore = () => {
let currentState = {} // 公共状态
function getState() { // getter
return currentState
}
function dispatch() {} // setter
function subscribe() {} // 发布订阅
return { getState, dispatch, subscribe }
}
- dispatch():
dispatch()这个函数的设计目标是有条件,有名称的修改状态。这个函数的设计关键有两点:名称和条件。redux的解决方法是分发一个action。action是一个对象,它包含了新的状态和type字段标明的操作名称。新的状态由action和当前状态决定。名称的问题解决了,条件怎么办呢?显然,action的分发是有条件的。这样,所有的问题就解决了。代码如下:
export const createStore = () => {
let currentState = {}
function getState() {
return currentState
}
function dispatch(action) {
switch (action.type) {
case 'plus':
currentState = {
...state,
count: currentState.count + 1
}
}
}
function subscribe() {}
return { getState, subscribe, dispatch }
}
如果我们的代码这样写,状态如何更新就在redux库中被写死了,不具有灵活性。作为一个比较完备的库而言,这样是不合理的,因此,我们需要将操作状态改变的代码抽出来,放到一个可以用户自定义的文件reducer.js中。此时,dispatch的代码如下:
import { reducer } from './reducer'
export const createStore = (reducer) => {
let currentState = {}
function getState() {
return currentState
}
function dispatch(action) {
currentState = reducer(currentState, action)
}
function subscribe() {}
return { getState, dispatch, subscribe }
}
接下来,我们来写reducer.js文件:
//reducer.js
const initialState = {
count: 0
}
export function reducer(state = initialState, action) {
switch(action.type) {
case 'plus':
return {
...state,
count: state.count + 1
}
case 'subtract':
return {
...state,
count: state.count - 1
}
default:
return initialState
}
}
- subscribe(): redux中状态的订阅和发布,我们采用一个设计模式 —— 观察者模式。实现代码如下:
import { reducer } from './reducer'
export const createStore = (reducer) => {
let currentState = {}
let observers = [] //观察者队列
function getState() {
return currentState
}
function dispatch(action) {
currentState = reducer(currentState, action)
observers.forEach(fn => fn())
}
function subscribe(fn) {
observers.push(fn)
}
dispatch({ type: '@@REDUX_INIT' }) //初始化store数据
return { getState, subscribe, dispatch }
}
这段代码中新添加了一个观察者队列observers=[],当新的组件订阅了状态,则将组件添加到队列中。当状态发生改变时,利用数组方法Array.forEach()挨个通知组件状态发生了变化。
react-redux
在react项目实际的使用中,订阅、获取状态的这些操作就很繁琐,而且每次使用都得写。除此之外,还增加了react代码和redux代码的耦合性。因此,react-redux库的出现帮助我们减少代码的重复并且降低了耦合性。在react-redux这个库中,最主要的是Provider和connect的实现
- Provider实现
Provider是一个react组件,它的功能是将store放到全局的
context对象中,然后在子组件需要时将需要的state取出来。具体的实现代码如下:
import React from 'react'
import PropTypes from 'prop-types'
export class Provider extends React.Component {
// 需要声明静态属性childContextTypes来指定context对象的属性,是context的固定写法
static childContextTypes = {
store: PropTypes.object
}
// 实现getChildContext方法,返回context对象,也是固定写法
getChildContext() {
return { store: this.store }
}
constructor(props, context) {
super(props, context)
this.store = props.store
}
// 渲染被Provider包裹的组件
render() {
return this.props.children
}
}
这段代码实现的功能有三个:从props中取出store;将store放入context中;渲染子组件
- connect实现
在考虑
connect如何实现之前,我们先来回顾一下connect的用法:
connect(mapStateToProps, mapDispatchToProps)(App)
connect函数实现的功能是将状态和action映射到props中,实现代码如下:
export function connect(mapStateToProps, mapDispatchToProps) {
return function(Component) {
class Connect extends React.Component {
componentDidMount() {
//从context获取store并订阅更新
this.context.store.subscribe(this.handleStoreChange.bind(this));
}
handleStoreChange() {
// 触发更新
// 触发的方法有多种,这里为了简洁起见,直接forceUpdate强制更新,读者也可以通过setState来触发子组件更新
this.forceUpdate()
}
render() {
return (
<Component
// 传入该组件的props,需要由connect这个高阶组件原样传回原组件
{ ...this.props }
// 根据mapStateToProps把state挂到this.props上
{ ...mapStateToProps(this.context.store.getState()) }
// 根据mapDispatchToProps把dispatch(action)挂到this.props上
{ ...mapDispatchToProps(this.context.store.dispatch) }
/>
)
}
}
//接收context的固定写法
Connect.contextTypes = {
store: PropTypes.object
}
return Connect
}
}
这段代码中,mapStateToProps和mapDispatchToProps这两个参数通过闭包的形式被记录下来,外层函数返回另一个函数,该函数传入一个component。在这个函数中,我们创建一个component组件类,在这个新创建的组件类中实现完成store的订阅、接收context传入的store参数,以及组件参数的传递等功能。
相关知识点
context
这部分内容,已经有一篇文章写的很好了,我就在这里当一下搬运工:聊一聊我对React Context的理解以及应用
观察者模式
- 定义:指多个对象间存在一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都能得到通知并被自动更新。
- 特点:
- 优点:
- 降低了目标与观察者之间的耦合关系,两者之间时抽象耦合关系。符合依赖倒置原则
- 目标与观察者之间建立了一套触发机制
- 缺点:
- 目标与观察者之间的依赖关系并没有完全解除,而且可能出现循环引用
- 当观察者对象很多时,统治的发布会花费很多时间,影响程序的效率
- 优点:
- 例子:在DOM节点上绑定事件函数,JS和DOM之间就是一种观察者模式
以上JS就是观察者,DOM就是被观察者,给DOM添加点击事件就相当于订阅了DOM,当DOM被点击,DOM就会通知JS触发绑定的函数。document.body.addEventListener("click", function(){ alert("hello world") },false) - 实现:
//观察者 class Observer { constructor (fn) { this.update = fn } } //被观察者 class Subject { constructor() { this.observers = [] //观察者队列 } addObserver(observer) { this.observers.push(observer)//往观察者队列添加观察者 } notify() { //通知所有观察者,实际上是把观察者的update()都执行了一遍 this.observers.forEach(observer => { observer.update() //依次取出观察者,并执行观察者的update方法 }) } } var subject = new Subject() //被观察者 const update = () => {console.log('被观察者发出通知')} //收到广播时要执行的方法 var ob1 = new Observer(update) //观察者1 var ob2 = new Observer(update) //观察者2 subject.addObserver(ob1) //观察者1订阅subject的通知 subject.addObserver(ob2) //观察者2订阅subject的通知 subject.notify() //发出广播,执行所有观察者的update方法 - 代码解释:这段代码中分别新建了两个类
- 观察者类:该类中有一个
update方法,用来接收当被观察者状态更新时观察者应该进行怎样的操作 - 被观察者类:该类中有一个
observers观察者队列,用来保存已经订阅了这个被观察对象的所有观察者;还有一个添加观察者的方法addObserver()和一个自身状态更新的方法notify()
- 观察者类:该类中有一个
装饰器模式
- 定义:装饰器模式允许向一个现有的对象添加新的功能,同时又不改变其结构。这种类型的设计模式术语结构式模式,它是作为现有类的一个包装。
- 分类:装饰器可以用来装饰类或者方法,但是不能用来装饰函数。
- 类的装饰:
在这段代码中@testable class MyTestableClass { // ... } function testable(target) { target.isTestable = true; } MyTestableClass.isTestable // true@testable就是一个装饰器,它修改了MyTestableClass这个类的行为,为它添加了静态属性isTestable。在testable()函数中,target表示装饰的类,在这个例子中也就是表示MyTestableClass- 方法的装饰
在这个例子中,利用class Math { @log add(a, b) { return a + b; } } function log(target, name, descriptor) { var oldValue = descriptor.value; descriptor.value = function() { console.log(`Calling ${name} with`, arguments); return oldValue.apply(this, arguments); }; return descriptor; } const math = new Math(); // passed parameters should get logged now math.add(2, 4);@log装饰了Math类里面的add()方法。实现在执行该方法的操作之前,先输出log。和装饰类不同,利用装饰器装饰方法时,传入三个参数:target:表示类的原型对象name:表示所要装饰的属性名descriptor:表示该属性的描述对象
- JS中的实现原理:JS中的装饰器本质也是一个函数,利用的是JS中object的descriptor。
- 属性描述符(property descriptor)对象:
- value:属性的值,默认是undefined
- writable:决定属性能否被赋值
- get:访问器函数(getter),函数或undefined,在取属性值时被调用
- set:设置器函数(setter),函数或undefined,在设置属性值时被调用
- enumerable:决定for in或Object.keys能够枚举该属性
- configurable:决定该属性是否能被删除,以及除value,writeable外的其他特性是否可以被修改
函数式编程
简单说,函数式编程是一种编程范式,也就是如何编写程序的方法论。显然,函数式编程属于方法论层面的东西。而编写程序的方法论,即编程范式有很多,常见的有命令式编程、函数式编程、逻辑式编程等等。在这里,我们着眼于函数式编程。函数式编程中的函数这个术语并不是指计算机编程中的函数,而是指数学中的函数。这个其实很好理解,假设函数式编程中的函数指的是计算机中的函数,那么函数是随处可见的。那么所有有函数的地方就都是函数式编程了,显然不是这样的。反证法,证明函数式编程的函数其实指的是数学中的函数。既然函数式编程的函数指的是数学中的函数,那么,首先我们来复习一下函数的概念:
函数在数学中表示两个非空集合之间的对应关系:输入值集合中的每项元素节能对应唯一一项输出值集合中的元素。 数学中的函数强调输入集合和输出集合之间的关系,并且还强调唯一性。现在,我们把他对应到编程中。在函数式编程中,我们关注的是数据的映射。和函数式编程相对应的是命令式编程,他关注的是解决问题的思路。我们一道经典的leetcode算法题翻转二叉树为例。如果我们按照函数式编程的思路来思考问题,我们会有如下的思路:
- 需要明白输入和输出是什么:显然在这个例子中,输入和输出都是二叉树。
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @return {TreeNode}
*/
var invertTree = function(root) {
// 这里写输入到输出的变化过程
return root
};
- 理清楚输入到输出经过了哪些变换:从输入到输出只是经过了翻转。所谓翻转就是原来所有的左节点现在都变成了右节点,原来所有的右节点现在都变成了左节点。也就是说这里只要交换一下位置就行了,结果如下:
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @return {TreeNode}
*/
var invertTree = function(root) {
if(root === null) {
return null
}
const left = invertTree(root.right)
const right = invertTree(root.left)
root.left = left
root.right = right
return root
};
例子不难,但是这中间体现了函数式编程的思想:我们只需要关注输入的数据和输出的数据即可。接下来,我们来了解一下函数式编程的几个特点:
- 函数是"一等公民":这是函数式编程得以实现的前提,因为我们基本的操作都是在操作函数。这个特性意味着函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。
- 声明式编程:函数式编程大多数时候都是在声明我需要做什么,而非怎么去做。这种编程风格叫做声明式编程。
- 惰性执行:所谓惰性执行指的是函数只在需要的时候执行,即不产生无意义的中间变量。
- 无状态和数据不可变:这是函数式编程的核心概念
- 数据不可变:它要求你所有的数据都是不可变的。也就是说假如需要修改一个对象,则应该创建一个新的对象,而不是在原来对象的基础上修改。换句话说,无论什么时候,原有对象都是不能直接被改变的。这一点react组件状态的更新和redux状态的改变都是如此。
- 无状态:强调对于一个函数,不管你何时运行,它都应该向第一次运行一样,给定相同的输入,给出相同的输出,完全不依赖外部状态的变化。我个人觉得这个概念类似于信号与系统中的时不变性。
- 没有副作用
- 纯函数
函数柯里化
函数柯里化(currying)在维基百科中的定义如下:
In mathematics and computer science, currying is the technique of translating the evaluation of a function that takes multiple arguments (or a tuple of arguments) into evaluating a sequence of functions, each with a single argument. 翻译成中文: 在数学和计算机科学中,柯里化是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术。
看到这么学术化的表述,真的,头都大了。所以,函数柯里化到底是个什么玩意呢?小编找了很多资料,发现在《函数式编程指北》这本书上的概念还是比较通俗易懂的,具体内容如下: curry的概念很简单:只传递给函数一部分参数来调用它,让他返回一个函数取处理剩下的参数。 从这个概念中,我们首先能看到函数柯里化的本质是一个函数。什么样的函数呢?一个处理函数参数的函数。如何处理函数参数呢?因为我们在调用函数时可能传入的参数数量不够,所以要做一个参数的拼接。这样处理之后的函数参数可以分几次传入。
还不懂函数柯里化是什么?没关系,我们来看一个例子:
var add = function(x) {
return function(y) {
return x + y;
};
};
var increment = add(1);
var addTen = add(10);
increment(2);
// 3
addTen(2);
// 12
在这段代码中,对我们定义了一个add函数,它接收一个参数,并且返回另一个函数。实现的功能是将传入到两个函数的参数相加。那么,他是如何实现这个功能的呢?首先,如果我们调用add函数的时候,会传入一个参数x;然后该函数会返回另一个函数,在这个函数中传入到add函数中的变量x会通过闭包的形式被记录下来。在这个函数中,又会传入一个新的变量y;最终由内层函数返回x+y的结果即可。在这个例子中,实现了函数参数的分层传递。这就是将函数柯里化的一个例子。
接下来,我们来实现一下函数的柯里化:
- 第一版:
数柯里化是实现函数参数的分层传递,那么最直接的想法就是将两次传入的参数合并起来,具体实现如下:函数的调用如下:var curry = function (fn) { var args = [].slice.call(arguments, 1); return function() { var newArgs = args.concat([].slice.call(arguments)); return fn.apply(this, newArgs); }; };这一版的代码只能是将函数function add(a, b) { return a + b; } var addCurry = curry(add, 1, 2); addCurry() // 3 //或者 var addCurry = curry(add, 1); addCurry(2) // 3 //或者 var addCurry = curry(add); addCurry(1, 2) // 3fn的参数做了一个分离,但是并没有完全实现柯里化。原函数的两个参数还是不能完全分开。在最后一次调用的时候,不符合我们的要求。我们需要的是类似fn()()的形式。虽然有柯里化的感觉了。但是,还不能完全符合我们的要求。 - 第二版:
在第一版的基础上加上传入参数长度的判断,得到第二版的代码:第二版的代码是可以实现函数柯里化了,但是还是有一些小问题:传入的参数不能在中间空位。// 第二版 function sub_curry(fn) { var args = [].slice.call(arguments, 1); return function() { return fn.apply(this, args.concat([].slice.call(arguments))); }; } function curry(fn, length) { length = length || fn.length; var slice = Array.prototype.slice; return function() { if (arguments.length < length) { var combined = [fn].concat(slice.call(arguments)); return curry(sub_curry.apply(this, combined), length - arguments.length); } else { return fn.apply(this, arguments); } }; } - 第三版:
在第二版的代码中添加占位符的判断,得到第三版的代码:// 第三版 function curry(fn, args, holes) { length = fn.length; args = args || []; holes = holes || []; return function() { var _args = args.slice(0), _holes = holes.slice(0), argsLen = args.length, holesLen = holes.length, arg, i, index = 0; for (i = 0; i < arguments.length; i++) { arg = arguments[i]; // 处理类似 fn(1, _, _, 4)(_, 3) 这种情况,index 需要指向 holes 正确的下标 if (arg === _ && holesLen) { index++ if (index > holesLen) { _args.push(arg); _holes.push(argsLen - 1 + index - holesLen) } } // 处理类似 fn(1)(_) 这种情况 else if (arg === _) { _args.push(arg); _holes.push(argsLen + i); } // 处理类似 fn(_, 2)(1) 这种情况 else if (holesLen) { // fn(_, 2)(_, 3) if (index >= holesLen) { _args.push(arg); } // fn(_, 2)(1) 用参数 1 替换占位符 else { _args.splice(_holes[index], 1, arg); _holes.splice(index, 1) } } else { _args.push(arg); } } if (_holes.length || _args.length < length) { return curry.call(this, fn, _args, _holes); } else { return fn.apply(this, _args); } } } var _ = {}; var fn = curry(function(a, b, c, d, e) { console.log([a, b, c, d, e]); }); // 验证 输出全部都是 [1, 2, 3, 4, 5] fn(1, 2, 3, 4, 5); fn(_, 2, 3, 4, 5)(1); fn(1, _, 3, 4, 5)(2); fn(1, _, 3)(_, 4)(2)(5); fn(1, _, _, 4)(_, 3)(2)(5); fn(_, 2)(_, _, 4)(1)(3)(5)
闭包
闭包是JavaScript语言中比较重要的一个概念,也是一个难点。在这一部分,我们就来唠一唠闭包。我们首先从它的定义聊起。MDN中的定义如下:
一个函数和对其周围环境状态(lexical environment, 词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包。也就是说,闭包可以让你在一个内层函数中访问道其外层函数的作用域。在JavaScript中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。 在这个定义中,有三个关键的点:
- 函数:闭包是一个和函数相关的概念
- 周围环境:闭包还和函数的周围环境相关。周围环境,也就是作用域。闭包和作用域也是相关联的
- 闭包可以允许在在内层函数中访问外层函数的作用域。 闭包的概念看起来挺抽象的,但是其本质就是函数和作用域。函数的概念在各种编程语言中都很常见,JS自然也在其中,这里就不赘述了。接下来,我们重点关注一下作用域。
作用域
关于JS中作用域的问题,我们需要分成两个阶段来看:
- ES6之前:
- 全局作用域:没有任何关键字声明的变量,作用域是全局作用域。该变量为全局变量
- 函数作用域:用
var关键字声明的变量,其作用域为函数作用域。该变量为局部变量
- ES6新增:
- 块作用域:用
let和const关键字声明的变量,其作用域是块作用域 关于这三个作用于的描述,我想可以直接从他的名称中就能知道。全局作用域自然是作用在整个程序的全局,函数作用域作用在一个函数的始终,而最后的块作用域显然是作用在某一个代码块中。这里我们对代码块的定义是一个花括号中所包含的代码就是一个代码块。除了作用域这个概念,我们还需要了解的一个概念是作用域链,其定义如下:
- 块作用域:用
如果将一个局部变量看作是自定义实现的对象的属性的话,那么可以换个角度来解读变量作用域。每一段JavaSCript代码(全局代码或函数)都有一个与之关联的作用域链(scope chain)。这个作用域链是一个对象列表或者链表,这组对象定义了这段代码“作用域中”的变量。当JavaScript需要查找变量x的值的额时候(这个过程乘坐“变量解析”),他会从链中的第一个对象开始查找,如果这个对象有一个名为x的属性,则会直接使用这个属性的值,如果第一个对象中不存在名为x的属性,JavaScript会继续查找下一个对象,以此类推。如果作用域链上没有任何一个对象含有属性x,那么就会认为这段代码的作用域链上不存在x,并最终抛出一个引用错误异常。 这一段描述是《JavaScript权威指南》中对于作用域链的描述。在这一段描述中,我们可以比较直观地了解到,作用域链是一个函数中引用变量的一个搜索的范围。在作用域和作用域链这两个概念中,需要注意一点:函数的作用域链在函数创建的时候就已经确定了。
闭包深入
从前面闭包的定义,我们知道闭包可以允许在在内层函数中访问外层函数的作用域。这句话看的人挺懵的。我们来解释一下:既然这里提到了内层函数和外层函数,那么这里面肯定存在一个函数的嵌套问题。那么闭包就一定发生在如下所示的两个函数当中。
function f1(){
function f2(){
}
}
显然f1()和f2()这两个函数存在嵌套关系,并且f1()是外层函数,而f2()是内层函数。f1()的作用域就是在f1的话括弧里面所包含的用var声明的全部变量。f2()的作用域也同理。这句话说的就是f2()可以使用f1()中的变量。为什么会如此呢?作用域链给了我们答案。在两个函数定义的时候,他们相应的作用域链也被定义。根据两个函数的嵌套关系,两个函数的作用域链的链表指向如下:
举个栗子
- 代码
const foo = (function() {
var v = 0
return () => {
return v++
}
})()
for(let i=0; i<10; i++){
foo()
}
console.log(foo())
- 执行结果:10
- 分析:
foo是一个自执行函数。只要定义就会被执行。而这个函数返回的结果也是一个函数。返回的函数的功能是将变量v自加。我们在循环里面调用了这个内部函数10次,变量自加10次。最后我们打印foo()时,v===10。因此,返回的结果是10。 - 注意:
- 这里需要注意
v++和++v的区别。前者是代码执行完之后,变量自加;后者是变量自加之后执行代码。 - 自执行函数:定义完后,无需调用自己执行的函数。定义方法是将函数用括号括起来,并在后面添加另一个括号用来传递参数
- 这里需要注意
函数方法fn.call()和fn.apply()
fn.apply():- 语法:
fn.apply(thisArg,[argsArray]) - 参数:
thisArg:必选参数。在fn函数运行时使用的this值。请注意,this可能不是该方法看到的实际值:如果这个函数处于非严格模式下,则指定为null或undefined时会自动替换为指向全局对象,原始值会被包装。argsArray:可选参数。一个数组或者类数组对象,其中数组元素将作为单独的参数传给fn函数。如果参数的值为null或undefined,则表示不需要传入或任何参数
- 描述:在调用一个存在的函数时,你可以为其指定一个
this对象。this之当前对象,也就是正在调用这个函数的对象。使用apply,你可以只写一次这个方法然后在另一个对象中继承它,而不用在新对象中重复写该方法。
- 语法:
fn.call():- 语法:
fn.call(thisArg, arg1, arg2, ...) - 参数:
thisArg:可选参数。函数运行时使用的this值arg1,arg2,...:指定参数的列表
- 描述:允许为不同的对象分配和调用术语一个对象的函数/方法
- 语法:
- 二者区别:
apply与call()非常相似,不同之处在于提供参数的方式。apply使用参数数组而不是一组参数列表。apply可以使用数组字面量(array literal),如fun.apply(this, ['eat', 'bananas']),或数组对象, 如fun.apply(this, new Array('eat', 'bananas'))。
[].slice.call(argument,1)?
这条语句,包括里面的参数在内,由四个部分组成:[],.slice()方法,.call()方法,argument对象。这四个部分中:
[]:表示空数组.slice():slice(start,end)用来提取数组中的某个部分,并且保存到新数组中。其中,新数组中包含下表为start的元素,不包含下表为end的元素。若未指定参数,则该方法返回整个数组。若指定的参数为复数,则从数组的尾部开始计算。.call():该方法用于调用当前函数functionObject,并棵同时使用指定对象thisObj作为本次执行时functionObjet函数内部的this使用。这一段话看的挺难受,我们来举个例子:
var obj = {name: "李四", age: 20};
function foo(a, b){
console.log(this.name);
console.log(a);
console.log(b);
}
foo.call(obj,12,true)
这段代码的运行结果如下:
这段代码很简单,首先定义了一个
obj对象和一个名字叫foo()的函数;函数里面打印了三个东西this.name以及通过形参变量传进去的参数a和b。对照call()方法的定义,我们来看一下。首先,这个方法是一个函数对象的方法。这个方法需要两种类型的参数:一种是一个普通的JS对象,一种是函数本身调用需要传入的参数。其中,传入的第一种类型参数的this变成了函数本身的this
arguments:argumens是一个对应于传递给函数的参数的类数组对象。这里的arguments不是数组,他只是和数组类似,并且是由函数传入的参数相关的一个JS对象。 最后切入正题,我们来看看[].slice.call(argument,1)到底是什么。先放结论:[].slice.call(argument,1)能将具有length属性的对象转换成数组。 举个栗子:
(function(a,b,c){
console.log(arguments.length);
console.log(typeof arguments);
console.log( arguments instanceof Object);
var args = [].slice.call(arguments,0)
console.log(args.toString())
}(1,2,3))
这段代码中关键的是args和原本的arguments之间的区别。我们通过调试代码发现:
args的_prop_是Array(0),而arguments的_prop_是Object。从这里可以发现,arguments本身不是数组,我们通过[].slice.call(arguments)这一行代码将非数组的JS对象转换成了数组。
最后,对这一部分内容做一下引申:
- 将函数的实际参数传唤成数组的方法:
- 方法一:
var args = Array.prototype.slice.call(arguments); - 方法二:
var args = [].slice.call(arguments, 0); - 方法三:
var args = []; for (var i = 1; i < arguments.length; i++) { args.push(arguments[i]); }
- 方法一:
- 转换成数组的通用函数:
var toArray = function(s){ //try语句允许我们定义在执行时进行错误测试的代码块。 //catch 语句允许我们定义当 try 代码块发生错误时,所执行的代码块。 try{ return Array.prototype.slice.call(s); } catch(e){ var arr = []; for(var i = 0,len = s.length; i < len; i++){ //arr.push(s[i]); arr[i] = s[i]; //据说这样比push快 } return arr; } }
注意:这条代码中的参数是包含长度属性的任意对象即可。若传入的参数长度为零或者未定义,则返回空数组
纯函数
- 定义:
- 如果函数的调用参数相同,则永远返回相同的结果。它不依赖于程序执行期间函数外部任何状态或数据的变化,必须只依赖于其输入参数
- 该函数不会产生任何可观察的副作用
- 可观察的副作用:
- 定义:一个可观察的副作用是在函数内部于其外部的任意交互。这可能是在函数内修改的外部变量,或者在函数里调用的额另外一个函数等
- 来源:
- 进行一个HTTP请求
- 改变数据
- 输出数据到屏幕或者控制台
- DOM 查询/操作
- Math.random()
- 获取当前时间
- 总结:只要是跟函数外部环境发生的交互就是副作用
- 态度:并不是要禁止一切副作用,而是说要让他们在可控的范围内发生
- 例子:
- 纯函数:
function priceAfterTax(prductPrice){ return (productPrice * 0.2) + productPrice }- 非纯函数:
上面两个例子中,第一个例子中函数的输出不依赖于外部变量,只由输入决定;第二个例子中,函数的输出除了依赖输入外,还依赖于外部的变量var tax = 20; function calculateTax(productPrice) { return (productPrice * (tax/100)) + productPrice; }tax - 使用纯函数的好处:
- 可缓存性
- 可移植性/自文档化
- 可测试性
- 合理性
- 并行代码