React Native Hook浅析——重头戏useEffect

前情提要《React Native Hook浅析——state处理》
请先忘记所有class组件相关的知识,忘记生命周期回调函数,忘记this,忘记this.state,忘记一层层向下传递的props,然后,开干。

rendering(渲染)

函数组件渲染是由state、props改变引发的,结合上一小节,我们可以知道当state或者props变化时,会调用React的render方法发起渲染,对于函数式组件,我们可以理解成重新执行函数组件的内容(箭头函数里的代码)。
基于此,组件每一次渲染都会有自己的props和state以及事件处理函数,也就是说,对于函数组件,每次渲染时,使用props、state的地方都相当于替换为常量:

const NumberView = (props: Props) => {
  const [num, setNum] = useState(0)

  return (
    <>  
      //    步骤        | ”看到“的num常量  | ”看到“的props.step常量  
      // 1、初始化       |     0          |         1
      // 2、点击Add Step |     0          |         2
      // 3、点击Add      |     2          |         2
      <Text>Num : {num}</Text>
      <TouchableOpacity style={styles.button} onPress={() => { setNum(num + props.step) }}>
        <Text style={{ fontSize: 16, color: 'white' }} >Add {props.step}</Text>
      </TouchableOpacity>
    </>
  );
}

export default (props: Props) => {
  const [step, setStep] = useState(1)
  return (
    <SafeAreaView style={styles.root}>
      //    步骤        | ”看到“的step常量
      // 1、初始化       |     1
      // 2、点击Add Step |     2
      // 3、点击Add      |     2
      <NumberView step={step} />
      <TouchableOpacity style={styles.button} onPress={()=>setStep(step+1)}>
        <Text style={{ fontSize: 16, color: 'white' }} >Add Step</Text>
      </TouchableOpacity>
    </SafeAreaView>
  );
};

在这里插入图片描述
对于事件处理函数,其也会“记住”当次渲染时的state或是props:

// setNum三次+1之后,触发handleClick,再连续触发两次,此后num值为5
const [num, setNum] = useState(0)

// 触发时handleClick“看到”的num常量为3
function handleClick(){
  setTimeout(()=>{
    // 3秒后,显示仍为3,因为setTimeout执行的函数“看到”的是触发handleClick时的处理函数“常量”,以及为3的num常量
    console.log(num);
  },3000)
}

effect(副作用)

定义

class中有生命周期处理函数,如常用的componentDidMount,componentDidUpdate和componentWillUnmount等,componentDidMount与componentDidUpdate会在渲染时同步的执行,componentWillUnmount会在组件卸载时调用,而在心智模型不一样的函数式组件中,思想应转变为React会根据我们当前的props和state同步到DOM,我们关心的是针对props和state等数据流变化时(初始化也是一种变化,相当于props赋值),我们的页面怎样展示,这种数据变化后,页面怎样展示,也即是在React组件中执行过数据获取、订阅或者手动修改过DOM,就是所谓的effect。

useEffect

React使用useEffect处理effect:

useEffect(() => {
    setTimeout(() => {
      // 快速点击5次后,输出依次是1、2、3、4、5
      // 因为每次setTimeout执行的函数“看到”的是,每次点击时当次渲染的事件处理函数“常量”,以及当次的num常量
      console.log(num);
    }, 3000)
  })
const [num, setNum] = useState(0)

useEffect实现基本原理与useState类似(事实上所有hook都类似,利用闭包+单链表),同时useEffect加上了依赖判断与effect清理的功能:

function useEffect(effect: EffectCallback, deps?: DependencyList)

useEffect(() => {
    // 每次执行effect的操作
    return xxx // effect清理(下文)
},
// 依赖(下文)
[deps])

上一篇文章中,我们可以看出借助Promise微任务,使用useEffect调度的effect不会阻塞浏览器更新屏幕,传给useEffect的函数会在浏览器完成布局与绘制之后,在一个延迟事件中被调用。

effect清理

某些effect在下次渲染或页面卸载时,需要执行一些清理动作,比如订阅动作、资源清理等,useEffect函数可以使用return返回一个资源清理函数,其会在下次渲染或页面卸载时执行。

useEffect(() => {
  Observable.subscribe(props.id, handleFun);
  return () => {
    Observable.unsubscribe(props.id, handleFun);
  };
});

上例中,若props.id从1被修改为2,则其effect清理过程如下:
1、React渲染了{id:2}的UI
2、屏幕绘制{id:2}的UI
3、React执行了{id:1}的return里的清理函数清除effect
4、React执行了{id:2}的effect
可以看到,上一次的effect会在重新渲染后被清除,effect跟第一节里的state、props、事件处理函数一样,都能“看到”属于各自渲染时的effect里的清理函数“常量”。

依赖

useEffect第二个参数可以指定这个effect需要“订阅”的依赖,只有指定依赖变更时才会执行effect:

  • 不传第二个参数时,每次渲染都会执行
  • 传[]时,只会在首次渲染时执行
  • deps不为空时,只有deps变更时,才会执行effect,如[param1, param2]
    只有当两次渲染的deps里的任意一个值不相同(通过Object.is对比)时,才会执行effect。

指定依赖项

一般情况下,使用useEffect都不建议使用如下形式:

useEffect(() => {
  doSomeThing();
  return clear;
});

这样会导致每次渲染doSomeThing与上一次渲染的clear都会触发,另一种情况则是漏了或者指定错了依赖项:

const [num, setNum] = useState(0)
console.log('------页面render-----', num);

useEffect(() => {
  const id = setInterval(() => {
    console.log('------设置-----', num);
    setNum(num + 1);
  }, 1000);
  return () => {
    console.log('------清除-----', num);
    clearInterval(id);
  }
}, [])
return <Text>Num : {num}</Text>

打印结果为:
------页面render----- 0 // 渲染{num:0}的UI
------设置----- 0 // 执行{num:0}的effect,设置为{num:1}
------页面render----- 1 // 渲染{num:1}的UI,由于依赖项没有变更([]),不会执行{num:1}的effect,也不会清除{num:0}的effect
------设置----- 0 // 还是{num:0}的effect的定时器
------页面render----- 1 // 为什么会又执行了一次渲染呢?请细看上一章hook简单原理中index!==0的逻辑
------设置----- 0 // 还是{num:0}的effect的定时器
------设置----- 0 // 还是{num:0}的effect的定时器

包含所有依赖

第一种也是最建议的方式,就是是在依赖中包含所有effect中用到的组件内的值,这样同时可以使后续维护时更清晰的明了此effect的依赖项。
上例中useEffect添加依赖[num],打印结果为:
------页面render----- 0
------设置----- 0
------页面render----- 1
------清除----- 0
------设置----- 1
------页面render----- 2
------清除----- 1
------设置----- 2

有时可能useEffect中的函数过长,或者使用了其他函数(下文细述),我们也不确定漏了哪些依赖,那么使用这条Lint规则就可以自动检测useEffect等hooks中,是否遗漏了哪些依赖项。

函数式更新

上一章中我们知道了对于useState来说,React会保证setState在组件的声明周期内保持不变,也就是说,在useEffect中,这些useState的setState或者useReducer的dispatch等可以不需指定依赖,同时,在上一章hook简单原理中,也可以知道setState这类方法提供了函数式更新的选项:

if (typeof newValue === "function") {
   // 函数式更新
   newValue = newValue(state[currentIndex]);
}

函数式更新会讲上一次渲染的“常量”提供出来,因此上例还可以改为:

useEffect(() => {
  const id = setInterval(() => {
    console.log('------设置-----', num);
    // 函数式更新
    setNum(oldNum=>oldNum + 1);
  }, 1000);
  return () => {
    console.log('------清除-----', num);
    clearInterval(id);
  }
// 无需依赖num
}, [])

打印与上面的是一样的。但是现在有个问题,定时器每次渲染都会重新启动,上一次的定时器会被清除,频繁这样操作总感觉不对劲,会造成CPU的无谓浪费。

使用useReducer解除依赖

假设上例中我们的num变更还依赖于props里的属性(或者其他state):

useEffect(() => {
  const id = setInterval(() => {
    console.log('------设置-----', num);
    // 函数式更新
    setNum(oldNum=>oldNum + props.step);
  }, 1000);
  return () => {
    console.log('------清除-----', num);
    clearInterval(id);
  }
// X,需要依赖props.step
}, [])

此时lint规则就会报错:需要依赖props.step。加上props.step的依赖固然可以解决问题,但实际业务中,可能会依赖多个其他看似无关的props或state,全加上去的话会显得不够“优雅”,同时,在本例中,props.step变更还会导致重新订阅定时器,这看起来很不符合常理。
对于这种更新依赖于另一个状态的值时,可以使用useReducer去替换,利用“作弊”般的手法,我们可以直接在reducer里面访问最新的props、state,同时可以帮助我们移除不必需的依赖,避免不必要的effect调用:

export default ({step = 1}) => {
  // 类似于上面的函数式更新
  const reducer = (state, action)=>{
    if(action.type = 'tick') {
      return state + step
    } else {
      return state
    }
  }
 
  const [num, dispatch] = useReducer(reducer,0)

  useEffect(() => {
    const id = setInterval(() => {
      console.log('------设置-----', num);
      // effect记住的是这个action
      dispatch({type:"tick"})
    }, 1000);
    return () => {
      console.log('------清除-----', num);
      clearInterval(id);
    }
    // 这个dispatch的依赖可以去掉
  }, [dispatch])

  console.log('------页面render-----', num);
  return (
    <>
      <Text>Num : {num}</Text>
    </>
  );
};

函数与Effect

函数依赖

函数式组件中,函数也是一种依赖

// 等价于const getData = (url = "") => ...
function getData(url = "") {
  return "Fake Data:" + url
}

useEffect(() => {
  getData("url1")
}, []) // lint报错,这里依赖于getData

useEffect(() => {
  getData("url2")
}, []) // lint报错,这里依赖于getData

如果把getData放到useEffect依赖里,那每次渲染都会触发effect。解决方法有两种:

  1. 对于不用依赖于props或state的getData函数,我们可以将其放到函数组件外面,渲染时不在范围内,也就不存在依赖了
  2. 对于使用到了props或state的getData函数,我们可以使用useCallback,将依赖函数转为数据流,提供给effect使用:
// 只会在初次渲染时“初始化”出getData依赖供使用,再次渲染时不会变化,因此不会每次渲染都会触发effect
const getData = useCallback((url) => {
  return "Fake Data:" + url
}, [])

// 有了正确的依赖
useEffect(() => {
  getData("url1")
}, [getData])

// 有了正确的依赖
useEffect(() => {
  getData("url2")
}, [getData])

函数传递

使用useCallback也可以让我们将依赖传给子组件使用,使子组件可以回调父组件的方法:

...
return <SubComponent getData={getData}/>;
...

// SubComponent
const SubComponent = ({getData})=>{
	let [data, setData] = useState(null);
	
	useEffect(() => {
	  getData("subUrl").then(setData);
	}, [getData]); 
}

但是对于这种跨组件的操作,更建议使用useReducer的dispatch,如果是多层传递,还可以结合使用上一章的useContext,实现轻量级redux实现更解耦的调用。
useCallBack与useMemo(useCallback(fn, deps) 相当于 useMemo(() => fn, deps),用于优化部分高消耗依赖项的初始化)详细用法下一章再整。

竞态

实际开发中,同一请求返回时间不确定的情况时有发生:

function axios(url) {
  // 模拟网络请求
  return new Promise(resolve => {
    setTimeout(() => {
      resolve("fake data : " + url)
      // 模拟每次请求的响应时间不一定
    }, Math.floor(Math.random()*10) * 1000)
  })
}

export default () => {
  const [data, setData] = useState("none")
  const [url, setUrl] = useState("none")

  useEffect(() => {
    const getData = async () => {
      const result = await axios(url);
      setData(result)
    }
    getData()
  }, [url])

  return (
    <>
      <Text>{data}</Text>
      <TextInput onChangeText={(text: string) => {
        setUrl(text)
      }}></TextInput>
    </>
  );
};

上例中快速输入多位text,data的最终显示大概率会与text不一致。输入为12时,有可能{text:12}的响应比{text:1}的先回来,那么显示的data就会是1而不是后发起的12。
解决此类竞态问题的关键是如何“取消”掉上一次的操作,如果getData支持异步取消的话,那就是最好的,可以直接在操作前取消旧的异步操作,否则可以使用简单的bool解决:

useEffect(() => {
  let cancel = false
  const getData = async () => {
    const result = await axios(url);
    if (!cancel) {
      // 在实际作用于设置的时候,检测是否被取消
      setData(result)
    }
  }
  getData()
  return () => {
    cancel = true
  }
}, [url])

useLayoutEffect

在class组件生命周期的思维模型中,副作用的行为和渲染输出是不同的,UI渲染是被props和state驱动的,并且能确保步调一致,但副作用并不是这样,useEffect的思维模型中,副作用变成了React数据流的一部分。
如果确实需要与class组件生命周期执行时机相同,可以使用useLayoutEffect替换useEffect,其在所有的DOM变更之后同步调用effect,在浏览器执行绘制之前,useLayoutEffect内部的更新计划将被同步刷新。

参考资料

Hook简介
useEffect 完整指南
How to fetch data with React Hooks
React Hooks原理探究

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值