在学习完了 react hook 后,我应用 hook 进行了页面的开发,但是开发中遇到了大大小小的问题,我对一些印象较为深刻的问题做了如下记录。
首先,关于声明,这里推荐用 const 来声明,也就是如下的声明方式。
const [data, setData] = useState(null);
const 声明的变量无法直接改变值,也就是我们想修改 data 这个状态,一定要通过 setData 这个方法才能实现,这样是一种对状态的保护,可以防止有时候一些无心之举修改状态。但是 const 本身对这个值是否被修改的定义就有些宽泛,const 只针对最外层的地址。
const [obj, setObj] = useState({ name: "张三", age: 20 });
obj.name = "李四"; // 不会报错
setObj(obj); // 不会使页面重新渲染
如上的代码在执行时是不会报错的,也不能说不符合预期,这一点稍后在用法中讨论。
如上的代码,我们修改了 obj 中的 name 属性,但是在 setObj 执行后,页面并没有重新渲染。
这是因为,在 useState 中对内容是否发生改变的判定,只看该状态的地址指向有没有发生改变,而对于引用数据类型,这种修改方式并不会改变地址的指向。那么实际应用起来,想修改引用数据类型的地址时,就需要一点额外的处理。
const [obj, setObj] = useState({ name: "张三", age: 20 });
let newObj1 = obj;
//地址传递地址没有发生改变,不会重新渲染
newObj1.name = "李四";
setObj(newObj1);
// 拓展运算符克隆数据
let newObj2 = { ...obj };
newObj2.name = "李四";
setObj(newObj2);
// 传入新对象
setObj({ name: "李四", age: 20 });
因为 useState 看的是地址的指向,所以我们想修改一个状态时,要么用上述代码克隆一份数据,要么用直接传入一个新的对象,单纯的地址传递,然后修改属性,因为地址指向没有发生改变,所以是不会触发页面的重新渲染。
首先我们要明确,克隆一份数据,或者新建一个对象传入,对于一些比较大的表单来说,都是很浪费性能的。而状态是否发生改变,影响的是页面是否重新渲染。从这个角度去思考, 如果是状态改变但是不需要页面重新渲染的场景,我们也就没必要去克隆数据了,这样可以减少性能的损耗,看下面两个场景。
// 场景1
function demo(){
const [obj,setObj] = useState({name:"",age:0})
function onValueChange(e){
let newObj = obj
// 在下面的操作中,因为共用地址,原状态已经改变了
newObj.age = e.target.value
// 即便没用下面的set方法,状态也会改变
setObj(newObj)
}
function showAge(){
window.alert(obj.age)
}
return (
<div>
<Input type="text" onChange={onValueChange}/>
<Button onClick={showAge}>Click me</Button>
</div>
)
}
场景 1 是应用了 antd 的 input 组件的一个情况,单向绑定了状态 obj 中的一条 age 属性,并没有用返回的 setObj 的方法,如果将代码中注释掉的那行代码开启,对结果也不会产生影响,但是这是满足需要的,当 input 内容改变的时候,通过 onChange 事件可以直接修改状态。这种情况我将其总结为:只会影响到自身的状态,不用返回的方法去修改,也满足需要,接下来再看场景 2。
// 场景2
function demo(){
const [obj,setObj] = useState({name:"",age:0})
function onValueChange(e){
let newObj = obj
newObj.age = e.target.value
setObj(newObj)
}
function showAge(){
window.alert(obj.age > 18 ? "成年" : "未成年")
}
return (
<div>
<Input type="text" onChange={onValueChange}/>
<p>{obj.age > 18 ? "成年" : "未成年"}</p>
<Button onClick={showAge}>Click me</Button>
</div>
)
}
如上图所示场景 2,因为初始化的时候 age 是 0,P 标签内的展示结果会是未成年,但是无论我们如何修改 Input 内的内容,因为 onChange 函数并没有修改状态的地址,页面都不会重新渲染,也就一直展示成未成年了。这种情况我将其总结为:自身的状态,会影响到其他组件的情况。
对于场景 1,自己的状态改变只影响自己,这种情况我们不去克隆一份数据是完全满足需求的,这种场景普遍适用于表单中的各种单独输入的 input 框。
对于场景 2,自己的状态会影响到其他组件的,就需要我们去克隆一份数据了,这种场景也很常见,像一些 input 框需要根据用户的一些操作来进行启用和禁用操作,还有一键初始化全部表单数据等。
所以在实际开发中,我们完全可以对不同的情况采取不同的处理,需要克隆的,进行数据克隆,不需要克隆的,直接修改状态的值就可以了。
我们首先提出这样一个疑问,hook 的开发者是否希望我们跳过返回的方法直接修改数据?这个答案我相信稍作思考,所有人都会得出一个答案,肯定是不希望的。因为无论是出于安全的角度去考虑,还是规范性的角度去考虑,直接去修改存储的状态都是一种不合理的操作。
所以上述的对于不需要重绘的情况采取的策略,可以说是一种不是办法的办法。
对于上面说的场景 2,我们已经知道了,我们是需要克隆数据的,但是不可否认的是,克隆依然是很浪费性能的一项操作,那么,我们既想让页面重新渲染,还不希望进行克隆这种操作,有没有办法呢?
答案当然是有的,那就是 immutable.js,使用 immutable,我们既能保证每一次新建的数据都是新的,还避免了损耗过多性能的问题。
本篇主要以 react hook 开发为主,immutable 仅做一个简单的介绍。
immutable.js 用最通俗的话讲,就是一个节约性能的克隆方式。仅改变我们要改变的数据节点,不改变的部分仍然沿用之前的数据节点。
immutable 这个单词的意思是一成不变的,也就是我们每一次传入想要修改的数据进行新建时,都没有改动原来的旧数据,保证原来的数据是不变的,immutable 返回给我们了新的数据,但是并非完全克隆。
这一个坑其实就比较基础了,useState 返回的修改状态的方法其实是异步修改数据的, 但是是同步的写法。这就产生了一个问题,原本的 setState 是有接受第二个参数作为数据修改完之后触发的回调函数的,但是 useState 返回的方法并没有接收第二个参数,那想在修改完了状态后进行操作,怎么处理?其实答案也很简单,就是用 useEffect 做单独监控。
function demo() {
const [pagination, setPagination] = useState({ pagiSize, page: 0 }); // 页码参数
// 请求数据的方法
function featchData() {
// do something...
}
// 绑定给antd的组件的onChange事件
function onPageChange(page, pageSize) {
setPagination({
page,
pageSize,
});
// 如在setPagination后面直接使用featchData()会获取之前的页码参数
}
// 作为替代componentDidMount
useEffect(() => {
featchData();
}, []);
// 单独监听页码参数
useEffect(() => {
featchData();
}, [pagination.page]);
return (
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
onChange={onPageChange}
/>
);
}
如上方所示代码,当页码发生改变触发事件后,修改页码参数,我们用一个 useEffct 单独监控 pagination 参数,页码改变后,应用最新的页码再请求数据。
上面的代码看似没问题,但是在实际应用中,却发现了另外的问题,那就是我们传了空数组的 useEffect,和传了 pagination 的 useEffect,这两个 hook 都执行了。
当我开发中发现这个问题是我觉得很奇怪,首先 useState 创建状态是同步的,也就是当执行到我单独监听 pagination 的时候,这个 pagination 状态已经创建完了,这个状态我并没有改变它,为什么这个作为监听数据是否发生变化的 hook 还是执行了?带着疑惑我去看了 useEffect 的源码,在源码身上我发现了问题的答案。
useEffect 如果传入第二个参数的工作流程,和不传第二个参数的工作流程有些不一样,如果传入第二个参数,useEffect 会先判断自己是否有存储这个状态,如果没有存储,会先存储一下该状态,并执行传入的函数,也就是第一个参数。
当页面重绘,代码重新执行一遍,再执行到该 useEffect 的时候,第二个参数传入,这时候发现之前有存储过该状态,就会进行遍历比较看数组中的状态是否发生变化,根据结果判断是否要执行相应的回调函数。
根据这个思路,为什么传入空数组会起到 componentDidMount 的作用也可以理解了,因为只有第一次存储状态的时候才执行了回调函数,我们作为第二个参数传入的空数组中并没有变量,每一次执行的时候,遍历结果自然是值没有。
在应用 hook 的一次开发中,我又遇到了新的问题,那就是 useState 返回的方法每一次调用都会更改状态的地址指向,看如下示例代码。
function demo(){
const [data,setData] = useState({id:undefined,totalNum:undefined,desc:undefined})
function getTotalNum(id){
// 发送ajax请求获取该id的totalNum
fetchData(id).then(res => {
data.totalNum = res.totalNum
})
}
// 事件处理函数
onValueChange(type,e){
if(type === 'select'){
data.id = e.target.value
getTotalNum(data.id)
}
else if(type === 'input'){
data.desc = e.target.value
}
setData({...data})
}
return (<div>
<Input onChange={onValueChange.bind(null,'input')}/>
<Select onChange={onValueChange.bind(null,'select')}>
<Option value="1">数据1<Option />
<Option value="2">数据2<Option />
<Select/>
<div/>)
}
这个业务情景,为当选中 select 的下拉选项时,会根据选中的数据把 id 存储起来,同时会根据 id 请求接口,获取该 id 对应的 totalNum,再将 totalNum 存起来,为了保证无论是触发了 select 还是 input,都会更新数据,在事件处理函数的底部进行 setData。看似没问题,但是在实际应用中我发现,totalNum 永远获取不到,准确的说是在请求接口的时候可以拿到,在该 promise 的 resolve 中打印 data 发现确实存进去了,可在整个 onValueChange 事件触发完后,数据还是丢失了。
带着疑惑我查阅了相关资料,最终问题得到了解决。useState 返回的方法的工作原理,是我们传进去的值,他会把值作为新的状态存储起来,同时把这个状态返回,供使用者使用,在方法内部,进行浅比较,如果地址发生改变,就会使页面重绘。
而根据这个思路,我们传入与原状态地址不同的数据,useState 把新的地址返回,并使页面重绘,我们看似是同一个变量来接收 useState 返回的状态,但是实际上地址已经改变了,在函数进入 if 判断后,在 promise 内的 data 还是旧的,而到了代码底部,因为我们用拓展运算符深克隆了一份,状态更新,变成了新的。前面还在操作旧数据,后面状态更新成了新的地址,修改的数据自然也就丢失了,而其实处理起来也很简单,在 resole 中也用 setData,使状态更新合并就可以了,也就是将 getTotalNum 函数做如下修改。
function getTotalNum(id) {
// 发送ajax请求获取该id的totalNum
fetchData(id).then((res) => {
data.totalNum = res.totalNum;
setData({ ...data });
});
}
从上面发生的一些问题,我进行了一些思考,在开发中我感觉 hook 虽然对代码有了精简,但是似乎并没有我想象中的好用,那么站在开发者的角度上考虑,开发者究竟希望我们用一种什么样的理念来使用 hook?
经过我对自己应用 hook 开发的过程总结,我得出了一个自己比较认可的答案,那就是开发者希望我们将逻辑彻底抽离。
在类式组件中,我们是把所有的状态都放在了 state 中,而且同一个生命周期函数只有一个生效,相比于 hook,无论是 useState 和 useEffect 我们都可以调用无数次。想一个极端的情况,我们所有的数据都用 useState 声明到最深层的基础类型数据,然后所有的状态都用 useEffect 做单独监控,这样我们避免了需要克隆数据的问题,也避免了地址相关的问题,无论连锁反应是这样的,都是 useEffect 单独去监听自己负责的数据,这种逻辑抽离,会使代码的逻辑变得非常简单易懂。
但是在实际开发中,我们把所有的数据全抽离成基础数据类型的状态也是不现实的,该拼成一个对象、数据的还是要拼,只是我们要避免将多个不相关的数据挂载在一个对象上的情况。
广义上的逻辑抽离,单独监听,这是开发者希望我们做的,而不是把所有的状态都糅杂在一个对象里,仅仅调用一次 useState 就结束,这样开发起来很痛苦,后续维护的阅读者也会很痛苦。
这个也是比较简单的应用问题了,就是函数式组件无法使用装饰器,在函数式组件中使用了装饰器会报错,但是我们都知道,装饰器其实就是高阶函数的一个简写方式,不用修饰器,采用最基本的方式注入就可以解决这个问题。
function demo(){
return (<div>测试组件<div/>)
}
export default withRouter(demo) // 无需注入参数的写法 -- @withRouter
export default inject('store')(demo) // 需要额外注入参数的写法 -- @inject('store')
最基本的无需额外注入参数的写法,和需要额外注入参数的写法稍微有些不一样,但是本质是一样的,只是需要额外注入参数的情况是应用了柯里化,每一次的执行结果都是返回一个新的函数,一层层返回,最终的返回结果是我们写的函数式组件。
但是还是可能会有这样的疑问,为什么用类式组件好端端的没有问题,换到函数式组件就不行了?其实原因很简单,因为 js 中,函数有函数提升,因为这个函数提升,导致代码和我们原本期望的执行顺序有些不一致,以下关于装饰器的内容参考阮一峰的 es6 标准入门:
var counter = 0;
var add = function () {
counter++;
};
@add
function foo() {
}
上面的代码,意图是执行后counter
等于 1,但是实际上结果是counter
等于 0。因为函数提升,使得实际执行的代码是下面这样。
@add
function foo() {
}
var counter;
var add;
counter = 0;
add = function () {
counter++;
};
所以,我们如果想在函数式组件中要装饰组件,可以采用高阶函数的形式直接执行,来避免出现问题。
踩了很多坑,但是回过头来看,其实都是一些很基础的东西,像地址传递,克隆,函数提升等等,这些点单独拎出来问一个前端工程师,他不可能说他不会这些知识点,但是想在应用 hook 开发中不踩坑却是很难的。
常常听人说,前端的 js 基础决定将来的高度,经过 hook 的学习、应用、踩坑的过程,让我深刻体会到了这句话。本次的踩坑分享,并不是纯粹的把问题抛出,问题怎么解决就结束了,可能我们应用的技术栈不同,可能未来的新技术需要学习的还很多,我希望的是我从学习到踩坑,到思考解决,到总结的一个过程给各位以启发,能找到合适自己的学习方式。