极客时间已完结课程限时免费阅读

02 | 如何通过闭包对象管理程序中状态的变化?

02 | 如何通过闭包对象管理程序中状态的变化?-极客时间

02 | 如何通过闭包对象管理程序中状态的变化?

讲述:石川

时长11:07大小10.15M

你好,我是石川。
通过上节课的学习,现在我们知道,函数式编程中存在副作用(side effect),而纯函数和不可变就是减少副作用的两个核心思想。那么按理说,我们要想把副作用降低到接近为零,就可以用纯函数,同时不接受任何参数。但是这样完全自我封闭的函数,也就几乎没有什么使用意义了。
所以,作为一个函数,还是要有输入、计算和输出,才能和外界有互动往来,我们的系统也才能“活”起来。而一个活的系统,它的状态肯定是在不停变化的,那么我们如何才能在不可变的原则下,来管理这种变化呢?
今天这节课,我们就一起来看看在函数式编程中,有哪些值是可变的、哪些不可变,以及如何能在状态更新的同时做到不可变。

值的(不)可变

首先,我们要搞清楚一个问题,值到底是不是可变的?在 JavaScript 中,值一般被分为两种:原始类型和对象类型。
先来看原始类型。像字符串或数字这种数据类型,都是属于原始类型,而它们本身是不可变的。举个例子:在 console.log 中输入 2 = 2.5,得到的结果会是 invalid,这就证明了我们不可能改变一个原始类型的值。
2 = 2.5 // invalid
然后是对象类型。在 JavaScript 中,像数组、对象这类数据类型就叫做对象类型,这类数据更像是一种数据结构或容器。那这样的“值”是否可变?其实通过上节课数组的例子,你能看到这类值是可变的,比如通过 splice 这种方法。
所以下面,我们就来看看在使用对象类型的值来存储状态的时候,要如何在更新状态的同时做到不可变。

React.js 中的 props 和 state

这里,我们就以 React.js 为例,来观察下它是用什么类型的值作为状态的。
说到状态,React 中有两个很重要的概念经常容易被混淆,分别是 props 和 state。props 通常是作为一个外部参数传入到函数里,然后作为静态元素输出在 UI 中渲染;state 则是一个内部变量,它会作为动态元素输出在 UI 中渲染,并根据行为更新状态。
在上面这个图例中,有一个静态的文案和一个动态的计数器。其中,props 就是“点击增加:”这个文案,它在页面上基本是不应该有什么变化的,就是一句固定的提示语,所以它就是 props,一个静态的“属性”。
而在计数按钮中,它的值是基于每次点击加一的,也就是基于点击动态变化的,所以我们说它就是 state,一个动态“状态”。
// 属性 props
class Instruction extends React.Component {
render() {
return <span>提示 - {this.props.message}</span>;
}
}
const element = <Instruction message="点击以下按钮增加:" />;
// 状态 state
class Button extends React.Component {
constructor() {
super();
this.state = {
count: 0,
};
}
updateCount() {}
render() {
return (<button onClick={() => this.updateCount()}> 点击了 {this.state.count} 次</button>);
}
}
那么回到刚才的问题,在 React.js 里,props 和 state 是什么类型的值呢?答案是对象,props 和 state 都是用对象来储存状态的。
可是,React 为什么用对象做属性和状态存储值类型呢?它还有没有其它选择呢?下面我们就来看看。

结构型值的不可变

我们先来思考一个问题:props 和 state 是不是必须的?
答案是,props 是必须的,而 state 不是。因为假设我们的页面是一个静态页面,那么我们渲染内容就好了,但这种纯静态的应用也完全无法和用户互动
所以在前端,纯静态的应用被叫做 dumb as f*ck。当然这是一个不太文明的词,不过话糙理不糙,而且它的意思也很明显,就是说这样的应用也太“笨”了。
我们的应用肯定需要和用户交互,而一旦有交互,我们就需要管理值的状态(state) 和围绕值设计一系列行为(behavior)。在这个过程中,我们需要考虑的就是一个值的结构性不可变的问题
所以接下来,我们就一起看看围绕值的结构性操作,都有哪些数据类型可以选择。

闭包和对象

首先是闭包(closure)和对象(object),这二者都可以对一个状态值进行封装和创建行为。
闭包最大的特点是可以突破生命周期和作用域的限制,也就是时间和空间的控制。
这里的突破生命周期的限制是指,当一个外部函数内嵌一个内部函数时,如果内嵌函数引用了外部函数的变量,这个变量就会突破生命周期的限制,在函数结束执行后,仍然存在。比如在下面闭包的例子中,我们创建了一个计数器,每次加 1,可以记住上一次的值。
而突破作用域的限制是指,我们可以把一个内部函数返回成一个方法在外部调用。比如以下代码中,counter 返回的 counting 方法,我们可以通过 counter1 来执行这个方法,从而就突破了 counter 作用域的限制。
function counter() {
let name = "计数";
let curVal = 0;
function counting() {
curVal++;
}
function getCount() {
console.log(
`${name}${curVal}`
);
}
return {counting,getCount}
}
var counter1 = counter();
counter1.counting();
counter1.counting();
counter1.counting();
counter1.getCount(); // 计数是3
同样地,我们也可以通过对象来封装一个状态,并且创建一个方法来作用于这个状态值。
var counter = {
name: "计数",
curVal: 0,
counting() {
this.curVal++;
console.log(
`${this.name}${this.curVal}`
);
}
};
counter.counting(); // 计数是1
counter.counting(); // 计数是2
counter.counting(); // 计数是3
所以,单纯从值的状态管理和围绕它的一系列行为的角度来看,我们可以说闭包和对象是同形态的(isomorphic),也就是说可以起到异曲同工的作用。比如上面闭包例子中的状态,就是对象中的属性,我们在闭包中创建的针对值的行为,也可以在对象中通过方法来实现。
你可能要问,我们对比它们的意义是什么呢?其实是因为它们在隐私(privacy)、状态拷贝(state cloning)和性能(performance)上。还是有差别的,而这些差别在结构性地处理值的问题上,具有不同的优劣势。
下面,我们就从这几个维度的不同点展开来看下。

属性的查改

实际上,你通过闭包的例子可以发现,除非是通过接口,也就是在外部函数中返回内部函数的方法,比如用来获取值的 getCount 方法,或者重新赋值的 counting 方法,不然内部的值是对外不可见的。
所以,它其实可以细粒度地控制我们想要暴露或隐藏的属性,以及相关的操作。
counter1.counting();
counter1.getCount();
而对象则不同,我们不需要特殊的方式,就可以获取对象中的属性和重新赋值。如果想要遵循不可变的原则,有一个 Object.freeze() 的方法,可以把所有的对象设置成只读 writable: false
但这里有一点需要注意,通过 freeze 会让对象所有的属性变得只读,而且不可逆。当然,它的好处就是严格遵守了不可变原则。
counter.name;
counter.initVal;
counter.counting();

状态的拷贝

所以到这里,我们可以发现,针对原始类型的数据,无需过度担忧值的不可变。
不过,既然应用是“活”的,就可能会有“一系列”状态。我们通常需要通过诸如数组、对象类的数据结构,来保存“一系列”状态,那么在面对这一类的数据时,我们如何做到遵循不可变的原则呢?
如何通过拷贝管理状态?
要解决这个问题,我们可以通过拷贝 + 更新的方式。也就是说,我们不对原始的对象和数组值做改变,而是拷贝之后,在拷贝的版本上做变更。
比如在下面的例子中,我们通过使用 spread,来展开数组和对象中的元素,然后再把元素赋值给新的变量,通过这种方式,我们完成了浅拷贝。之后,我们再看数组和对象的时候,会发现原始的数据不会有变化。
// 数组浅拷贝
var a = [ 1, 2 ];
var b = [ ...a ];
b.push( 3 );
a; // [1,2]
b; // [1,2,3]
// 对象浅拷贝
var o = {
x: 1,
y: 2
};
var p = { ...o };
p.y = 3;
o.y; // 2
p.y; // 3
所以可见,数组和对象都是很容易拷贝的,而闭包则相对更难拷贝。
如何解决拷贝性能问题?
从上面的例子中,我们可以看到通过对状态的拷贝,是可以做到不可变,不过随之而来的就是性能问题
如果这个值只改变一两次,那就没问题。但假设我们的系统中有值不停在改变,如果每次都拷贝的话,就会占据大量内存。这样一来,我们应该如何处理呢?
实际上,在这种情况下,有一个解决方案就是用到一个类似链表的结构,当中有一组对象记录改变的指数和相关的值。
比如下面的 [3, 1, 0, 7] 这组数组中,我们把第 0 个值变成 2,第 3 个值变成 6,第 4 个值添加 1,形成了 [2, 1, 0, 6, 1]。那么如果我们只记录变化的话,就是 0:2、3:6 和 4:1 这三组对象,是不是就减少了很多内存占用?
其实目前在市面上,已经有很多成熟的三方库比如 immutable.js,它们会有自己的数据结构,比如 array list 和 object map,以及相关算法来解决类似的问题了。

性能的考虑

我们接着再来看看性能上的考虑。
从性能的角度来讲,对象的内存和运算通常要优于闭包。比如,在下面第一个闭包的例子中,我们每次使用都会创建一个新的函数表达。
而第二个对象的例子中,我们通过 bind 将 this 绑定到 greetings2 上,这样一来,PrintMessageB 就会引用 greetings2.name 来作为 this.name,从而达到和闭包一样的效果。但我们不需要创建一个闭包,只需要将 this 指向引用的对象即可。
// 闭包
function PrintMessageA(name) {
return function printName(){
return `${name}, 你好!`;
};
}
var greetings1 = PrintMessageA( "先生" );
greetings1(); // 先生,你好!
// 对象
function PrintMessageB(){
return `${this.name}, 你好!`;
}
var greetings2 = PrintMessageB.bind( {
name: "先生"
} );
greetings2(); // 先生,你好!

总结

这节课,我们一起深入理解了函数式编程中的不可变。我们需要重点关注的,就是对象和闭包在处理不可变问题上的不同优势。
在属性和方法的隐私方面,闭包天然对属性有保护作用,同时它也可以按需暴露接口,来更细粒度地获取或重新给状态赋值。但是它和我们要解决的问题,似乎关系不大。
而对象不仅可以轻松做到 props 整体不可变,而且在需要 state 变化时,在拷贝上也更有优势。不过从性能的角度来看,如果拷贝的量不大,也许它们的性能差不多,但如果是一个高频交互的界面,微小的差别可能就会被放大。
所以总结起来,在 React.js 中,它选择使用对象作为 props 和 state 的值类型,能更容易保证属性和状态值的整体不可变;而且面对状态的变化,它也更容易拷贝;在处理高频交互时,它的性能也会更好。
而闭包虽然有隐私上的优势和更细粒度的操作,可是在应用交互和状态管理这个场景下,它并没有什么实际的作用。所以,有利于使用对象的条件会相对多一些。
最后,你也可以再来复习下这两种方式的优劣势。其实,基于 React.js 的例子,你可以发现,不同的数据类型和处理变与不变的方式,并没有绝对的好与不好,而是需要根据具体情况,来确定哪种方式更适合你的程序和应用所需要支持的场景。

思考题

我们在提到值的状态拷贝时,说 spread 做到的是浅拷贝,那么你是否了解与之对应的深度拷贝?它会不会影响状态的管理?
欢迎在留言区分享你的思考和答案,也欢迎你把今天的内容分享给更多的朋友。

延伸阅读

分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 6

提建议

上一篇
01 | 函数式vs.面向对象:响应未知和不确定
下一篇
03 | 如何通过部分应用和柯里化让函数具象化?
unpreview
 写留言

精选留言(13)

  • WGH丶
    2022-09-28 来自北京
    有一个说法是:闭包是带数据的行为,对象是带行为的数据。

    作者回复: 很好地描述了两者相对的从属关系

    24
  • I keep my ideals...
    2022-09-22 来自北京
    如果要实现值的绝对不可变应该使用深拷贝,这样对拷贝出来的复杂数据结构进行修改时才能保证不会对原始数据造成影响

    作者回复: 是的

    5
  • John Bull
    2022-09-22 来自北京
    spread 展开语法仅能做到浅拷贝,因为仅遍历一层。在开发中,比较常用的深拷贝方式是:JSON.parse(JSON.stringify(obj))。虽然stringify方法在转化JSON字符串时有不少特殊状况。这种方式不会影响状态,因为stringify方法返回的是一个常量字符串。

    作者回复: 是的,虽然React.PureComponent中的shouldComponentUpdate() 基于性能考虑,不建议用深对比和JSON.stringify();但如果程序中确实是需要处理复杂的数据结构变化的话,可以用force update或immutable-js来满足类似的需求。

    4
  • Alison
    2022-09-22 来自北京
    通常更新state的时候框架会用Object.is来判断2个数组/对象是否相等,浅拷贝对象时,因拷贝的是引用地址,所以Object.is对比后的返回值会是true,状态就无法正常更新; 深拷贝对象的话,拷贝的是值,此时会产生新的引用地址,所以Object.is对比后的返回值是false,状态会进行更新

    作者回复: 在React,早期有一个shallowCompare附加功能,后面被React.memo和React.PureComponent取代了,但是底层逻辑类似,仍然是一个浅对比。如果想对比更复杂的对象,React.memo也支持在第二个实参传入自定义的对比功能。

    2
  • 奕晨
    2022-09-22 来自北京
    spread 做到的是浅拷贝,那么你是否了解与之对应的深度拷贝? 需要根据数据类型区分,若是对象的话,浅拷贝后,修改其中一个值,会影响另一个值,拷贝的是对象的地址;深度拷贝就是重新创建一个新的地址,修改其中的值,互不影响。 它会不会影响状态的管理? 需要针对props 和state区分,根据值的类型判断,props不会,state会影响。
    展开

    作者回复: 对于嵌套的对象,是有这样的影响。

    1
  • Hello,Tomrrow
    2022-09-22 来自北京
    对象或数组的浅拷贝,是简单的值的复制,这对于对象属性值或数组元素是简单类型来说没有问题;如果对象属性值或数组元素是复杂类型,存的是一个内存地址,对内存地址的复制,只是多了一个指向同一个空间的指针。这时需要进行深拷贝,常用的方式通过递归的方式。 深拷贝,是不会影响状态管理的。
    展开

    作者回复: 深拷贝的简单实现是JSON.parse(JSON.stringify(obj)),不过考虑到JSON safe和性能问题,递归是会更好一些。 React中的setState()是浅合并而不是深拷贝,会不会影响看情况,如果是“复杂类型”也就是嵌套对象,那就会被影响了。

    1
  • Nuvole Bianche
    2023-01-14 来自上海
    也许一方面是我自身的能力没达到一定高度,另一方面也感觉作者大大总是用一些看上去比较“假大空”的词汇。每当自己看到这些词汇时,有些不知所云,一时难以理解。然后自己试着根据自己已有的知识,找一个可能与之对应、但更实在的词汇来代替时,就突然明白,原来作者是这个意思呀。 比如原文中:所以,它其实可以细粒度地控制我们想要暴露或隐藏的属性,以及相关的操作。 这个细粒度说白了不就是更加可预测和可控吗? 可偏偏搞个大词,还是有些不习惯。 可能这就是作者在开篇说的入门既是巅峰者对菜鸡小白的俯视吧。 我还是的咬牙坚持一下。
    展开
  • 雨中送陈萍萍
    2022-11-03 来自北京
    好像要老师画图的软件,老师可以告知一下麽

    作者回复: Mac自带的keynote,没有特别的工具哈,类似于Windows中的ppt。

  • 2022-11-01 来自北京
    比如下面的 [3, 1, 0, 7] 这组数组中,我们把第一个值变成 2,第三个值变成 6,第 4 个值添加 1,形成了 [2, 1, 0, 6, 1]。 老师,这里没太理解呢,第3个值变成6,不是 2,1,6 吗?

    作者回复: 可能是描述的问题,我再改改。因为是数组,这里我们从0开始计算第一位,然后第3个值变成6是第四位,再然后第4个值添加1是第五位。

    共 2 条评论
  • 哎呦先生
    2022-10-31 来自北京
    老师,扩展运算符例子那数组元素用引用类型的数据结构是不是比较合理,原始类型的数据无法深拷贝浅拷贝的内存地址区别,容易迷糊。

    作者回复: 这里咱们用的数组和对象就是引用类型的数据结构哈。 举个例子: var a = [1,2,3]; var b = a; a[3] = 4; console.log(b); //返回 [1,2,3,4] 你可以看到b引用的是a的数组中的元素。

  • Change
    2022-10-11 来自北京
    文中提到闭包比较难实现拷贝,比较有疑问? 1. 闭包如果返回属性则失去对属性保护的意义。 2. 如果不返回通过哪种方式是实现属性拷贝。

    作者回复: 1. 闭包如果返回属性则失去对属性保护的意义。 --------------------------------- 是的,所以对象的属性是公开的,闭包的意义就是隐藏。 2. 如果不返回通过哪种方式是实现属性拷贝。 --------------------------------- 这种情况下,就要在闭包嵌套层加获取权限的方法。

  • Chaos浩
    2022-09-22 来自北京
    闭包和bind,经常见到这两个概念但一直一知半解,这一课看完豁然开朗

    作者回复: 很高兴能帮助你理解

  • RichardZhang
    2022-09-22 来自北京
    好文打卡,期待周六。

    作者回复: 我们周六见