learn-tech/专栏/JavaScript进阶实战课/02如何通过闭包对象管理程序中状态的变化?.md
2024-10-16 06:37:41 +08:00

13 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        02 如何通过闭包对象管理程序中状态的变化?
                        你好,我是石川。

通过上节课的学习现在我们知道函数式编程中存在副作用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 提示 - {this.props.message}; } } const element = ;

// 状态 state class Button extends React.Component { constructor() { super(); this.state = { count: 0, }; } updateCount() {} render() { return (<button onClick={() => this.updateCount()}> 点击了 {this.state.count} 次); } }

那么回到刚才的问题在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做到的是浅拷贝那么你是否了解与之对应的深度拷贝它会不会影响状态的管理

欢迎在留言区分享你的思考和答案,也欢迎你把今天的内容分享给更多的朋友。

延伸阅读

Component State - React Props vs State ReactJS: Props vs. State Closure vs. Object