React-hook 开发踩坑记

React-hook 开发踩坑记

在学习完了 react hook 后,我应用 hook 进行了页面的开发,但是开发中遇到了大大小小的问题,我对一些印象较为深刻的问题做了如下记录。

坑 ×1:修改状态不一定会使页面重新渲染

useState 对状态的判别

首先,关于声明,这里推荐用 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 看的是地址的指向,所以我们想修改一个状态时,要么用上述代码克隆一份数据,要么用直接传入一个新的对象,单纯的地址传递,然后修改属性,因为地址指向没有发生改变,所以是不会触发页面的重新渲染。

如何应用 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,我们既能保证每一次新建的数据都是新的,还避免了损耗过多性能的问题。

immutable.js

本篇主要以 react hook 开发为主,immutable 仅做一个简单的介绍。

immutable.js 用最通俗的话讲,就是一个节约性能的克隆方式。仅改变我们要改变的数据节点,不改变的部分仍然沿用之前的数据节点。

immutable 这个单词的意思是一成不变的,也就是我们每一次传入想要修改的数据进行新建时,都没有改动原来的旧数据,保证原来的数据是不变的,immutable 返回给我们了新的数据,但是并非完全克隆。

坑 ×2 useState 返回的方法其实是异步修改数据

这一个坑其实就比较基础了,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 的工作流程是怎么样的?

上面的代码看似没问题,但是在实际应用中,却发现了另外的问题,那就是我们传了空数组的 useEffect,和传了 pagination 的 useEffect,这两个 hook 都执行了。

当我开发中发现这个问题是我觉得很奇怪,首先 useState 创建状态是同步的,也就是当执行到我单独监听 pagination 的时候,这个 pagination 状态已经创建完了,这个状态我并没有改变它,为什么这个作为监听数据是否发生变化的 hook 还是执行了?带着疑惑我去看了 useEffect 的源码,在源码身上我发现了问题的答案。

useEffect 如果传入第二个参数的工作流程,和不传第二个参数的工作流程有些不一样,如果传入第二个参数,useEffect 会先判断自己是否有存储这个状态,如果没有存储,会先存储一下该状态,并执行传入的函数,也就是第一个参数。

当页面重绘,代码重新执行一遍,再执行到该 useEffect 的时候,第二个参数传入,这时候发现之前有存储过该状态,就会进行遍历比较看数组中的状态是否发生变化,根据结果判断是否要执行相应的回调函数。

根据这个思路,为什么传入空数组会起到 componentDidMount 的作用也可以理解了,因为只有第一次存储状态的时候才执行了回调函数,我们作为第二个参数传入的空数组中并没有变量,每一次执行的时候,遍历结果自然是值没有。

useState 的工作流程是怎么样的?

在应用 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 });
  });
}

思考:react hook 的核心理念究竟是什么?

从上面发生的一些问题,我进行了一些思考,在开发中我感觉 hook 虽然对代码有了精简,但是似乎并没有我想象中的好用,那么站在开发者的角度上考虑,开发者究竟希望我们用一种什么样的理念来使用 hook?

经过我对自己应用 hook 开发的过程总结,我得出了一个自己比较认可的答案,那就是开发者希望我们将逻辑彻底抽离。

在类式组件中,我们是把所有的状态都放在了 state 中,而且同一个生命周期函数只有一个生效,相比于 hook,无论是 useState 和 useEffect 我们都可以调用无数次。想一个极端的情况,我们所有的数据都用 useState 声明到最深层的基础类型数据,然后所有的状态都用 useEffect 做单独监控,这样我们避免了需要克隆数据的问题,也避免了地址相关的问题,无论连锁反应是这样的,都是 useEffect 单独去监听自己负责的数据,这种逻辑抽离,会使代码的逻辑变得非常简单易懂。

但是在实际开发中,我们把所有的数据全抽离成基础数据类型的状态也是不现实的,该拼成一个对象、数据的还是要拼,只是我们要避免将多个不相关的数据挂载在一个对象上的情况。

广义上的逻辑抽离,单独监听,这是开发者希望我们做的,而不是把所有的状态都糅杂在一个对象里,仅仅调用一次 useState 就结束,这样开发起来很痛苦,后续维护的阅读者也会很痛苦。

坑 ×3 函数式组件无法使用装饰器

这个也是比较简单的应用问题了,就是函数式组件无法使用装饰器,在函数式组件中使用了装饰器会报错,但是我们都知道,装饰器其实就是高阶函数的一个简写方式,不用修饰器,采用最基本的方式注入就可以解决这个问题。

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 的学习、应用、踩坑的过程,让我深刻体会到了这句话。本次的踩坑分享,并不是纯粹的把问题抛出,问题怎么解决就结束了,可能我们应用的技术栈不同,可能未来的新技术需要学习的还很多,我希望的是我从学习到踩坑,到思考解决,到总结的一个过程给各位以启发,能找到合适自己的学习方式。

你可能感兴趣的:(react-hook)