react-native中onlayout导致的bug

3,522 阅读6分钟

最近在解决一个rn项目中ios标题抖动问题,最后发现原因是onlayout是宏任务导致的。

一.标题组件

标题组件是这样的。所有页面的标题都共用一个标题实例a,进入页面的时候给a更新状态,组件内有一个对象containLayout,用来记录标题左中右部件的宽度。左中右部件是3个组件,根据状态渲染内容,各自绑定了一个onLayout事件,它有两个参数,第一个是event,第二个是left/center/right。对应左中右部件。 onLayout的逻辑为,在event中获取当前部件的宽度,并赋值给onLayout的left/center/right。如果containLayout的left、center、right属性都有值,就根据left、right、屏幕宽度确定center宽度,然后this.setState(containLayout)。render重新渲染,左中右部件的宽度为containLayout的left/center/right的值

二.抖动发生流程

根据console.log输出,抖动发生的流程如下:

在a页面,containLayout的left/right/center都有确定了值,然后进入了b页面。b页面中,render触发,由于左边和中间部件的内容都发生了改变,左边从50宽度变成10,中间从70宽度变成了74。所以触发了onLayout(event, 'left')和onLayout(event, 'center')。

ps:ios中onLayout的触发顺序是不确定的。这个结论是测试得出的,找不到官方文档,但是一下两篇文章均有提及:

1.onLayout(event, 'left')先触发,就会发生抖动。流程如下:

  1. onLayout(event, 'left')触发,从event获取left部件的宽度,为10,containLayout.left更新,containLayout此时如下:
{
	// 从50更新为10
	left: 10, 
	// 上个页面计算的
	center: 70,
	// 上个页面计算的
	right:10 
}

因为left、right、center都有值。进入center宽度计算逻辑,根据left、right、屏幕宽度计算得出,center宽度不需要裁剪。还是70,最后执行setState({ containLayout })

  1. setState后直接触发了render函数,onLayout(event, 'center')并没有马上触发

  2. render之后center 从74变成了70, 又触发了一个onLayout(event, 'center')。方便区分,称之为onLayout2(event, 'center')

  3. onLayout(event, 'center') 触发,event.nativeEvent.layout.width 取值为74!因为left、right、center都有值。进入center宽度计算逻辑,根据left、right、屏幕宽度计算得出,center宽度不需要裁剪。还是74,最后执行setState({ containLayout })

  4. 第四步的setState又触发了render函数,因为第3步center宽度已经变成了70了,现在又变成74,又触发了一个onLayout3(event, 'center')

  5. 执行onLayout2(event, 'center'),因为onLayout2是第3步触发的,event.nativeEvent.layout.width取值为70!然后还是不用裁剪,

    执行setState({ containLayout }),又执行了render,把center宽度从74变为70!

  6. 然后onLayout3执行,又把宽度变成74,执行render之后同时又产生一个onLayout4。。。。。

标题的center部分宽度就一直在70和74之间重复徘徊。由于<Text>组件宽度不够的时候会显示...省略号,所以看起来闪烁的特别明显

2.onLayout(event, 'center')先触发,就不会发生抖动。流程如下:

  1. onLayout(event, 'center')触发,从event获取center部件的宽度,为74,containLayout.center更新,containLayout此时如下:

    {
       // 上个页面计算的
       left: 50, 
       // 从70更新为74
       center: 74,
       // 上个页面计算的
       right:10 
     }
    

    因为left、right、center都有值。进入center宽度计算逻辑,根据left、right、屏幕宽度计算得出,center宽度不需要裁剪。还是74,最后执行setState({ containLayout })

  2. setState后直接触发了render函数,onLayout(event, 'left')并没有马上触发

  3. render之后 center 从 74 变成了 74, 没触发onLayout。

  4. onLayout(event, 'left') 触发,event.nativeEvent.layout.width 取值为10。left从50变为10。因为left、right、center都有值。进入center宽度计算逻辑,根据left、right、屏幕宽度计算得出,center宽度不需要裁剪。还是74,执行setState({ containLayout })。

  5. render 后发现left 和 center 的宽度没有任何改变,没触发onLayout。

3.解决

从上面两点看出,抖动是因为onLayout(event, 'left')的时候不能第一时间获取当前页面的center部件的宽度,而是使用了上个页面的center宽度,才导致的循环更新宽度。因为center部分的宽度是由title决定的,这样的话只需要在更新title的时候判断一下title是否和旧值一样,不一样就把containLayout.center就清空即可。

三.顺序问题

从上面发生抖动的流程中可以推测出,onLayout函数是一个宏任务,state状态变更是一个微任务。所以每次出发setState之后,都直接运行了render,然后才运行上一轮render触发的onLayout。下面写点demo证明一下

1.setState

虽然官方明确说明state的更新是异步的,但是没有详细说明;也没看过源码,所以不知道是微任务还是宏任务。这里跟vue类比一下,setState其实就是相当于data里面的更改,vue data里面的变更就是使用nextTick,nextTick首选promise.then对收集的update事件进行一次批量运行。所以状态的更改都应该是在微任务里面,这样才能保证界面渲染在状态更新之后。我找了一下react setState方法相关的文章,没找到明确的说明。但是我觉得以下测试能说明state变更是微任务

import React from 'react';

function App() {
  const [count, setCount] = React.useState(0);
  function add () {
    setTimeout(() => {
      console.log(2)
      //while (true) {
      //  ;
      //}
    })
    Promise.resolve().then(() => {
      console.log(1)
      //while (true) {
      //  ;
      //}
    })
    setCount(count + 1);
  }
 
  return (
    <div className="App" onClick={() => add()}>
      点击{count}
    </div>
  );
}

export default App;
  1. 如上代码,控制台显示12,页面显示点击1。这一步首先确定了宏任务和微任务的运行顺序正常。
  2. 现在我们把then里面的while注释去掉。控制台显示1,页面显示点击0。这一步确认了渲染确实在微任务执行完毕之后才执行。
  3. 最后我们把then里面的注释加上,把setTimeout里面的while注释去掉。如果state的更新是宏任务,他会在setTimeout之后才运行,而setTimeout里面是一个死循环,所以控制台输出应该是12,页面显示点击0。否则,控制台显示12,页面显示点击1
  4. 第三步的运行结果是控制台输出12,页面显示点击1。所以state的变更是微任务。

2.onlayout

接下来证明一下onlayout是一个宏任务。首先在rn文档onlayout说明中,表示onlayout会在mount和布局更改的时候触发。以下demo中,在componentDidMount的时候设定了一个宏任务和一个微任务。观察其输出

import React, { Component } from "react";
import {
  Text
} from "react-native";

class TestPage extends Component {

  constructor(props) {
    super(props);
    this.state = {
      name: 'lujiajian'
    }
    this.onLayoutHandel.bind(this);
  }

  componentDidMount () {
    console.warn('componentDidMount')
    setTimeout(() => {
      console.warn('setTimeout')
    }, 0)
    Promise.resolve().then(res => {
      console.warn('first then')
    })
  }

  onLayoutHandel (event) {
    console.warn('onLayoutHandel')
    event.nativeEvent.layout
  }

  render() {
    console.warn('render')
    const { name } = this.state;
    return (
      <Text onLayout={this.onLayoutHandel}>{name}</Text>
    );
  }
}

export default TestPage

输出结果为:render、componentDidMount、first then、setTimeout、onLayoutHandel。onLayoutHandel输出在setTimeout之后,说明不可能是微任务。除非他的触发时机在componentDidMount之后,但这个时机我暂时没想到办法确认。