New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

4.优化setState和ref的实现 #27

Open
MuYunyun opened this Issue Aug 5, 2018 · 2 comments

Comments

Projects
None yet
2 participants
@MuYunyun
Owner

MuYunyun commented Aug 5, 2018

该系列文章在实现 cpreact 的同时理顺 React 框架的核心内容

项目地址

同步 setState 的问题

而在现有 setState 逻辑实现中,每调用一次 setState 就会执行 render 一次。因此在如下代码中,每次点击增加按钮,因为 click 方法里调用了 10 次 setState 函数,页面也会被渲染 10 次。而我们希望的是每点击一次增加按钮只执行 render 函数一次。

export default class B extends Component {
  constructor(props) {
    super(props)
    this.state = {
      count: 0
    }
    this.click = this.click.bind(this)
  }

  click() {
    for (let i = 0; i < 10; i++) {
      this.setState({ // 在先前的逻辑中,没调用一次 setState 就会 render 一次
        count: ++this.state.count
      })
    }
  }

  render() {
    console.log(this.state.count)
    return (
      <div>
        <button onClick={this.click}>增加</button>
        <div>{this.state.count}</div>
      </div>
    )
  }
}

异步调用 setState

查阅 setState 的 api,其形式如下:

setState(updater, [callback])

它能接收两个参数,其中第一个参数 updater 可以为对象或者为函数 ((prevState, props) => stateChange),第二个参数为回调函数;

确定优化思路为:将多次 setState 后跟着的值进行浅合并,并借助事件循环等所有值合并好之后再进行渲染界面。

let componentArr = []

// 异步渲染
function asyncRender(updater, component, cb) {
  if (componentArr.length === 0) {
    defer(() => render())       // 利用事件循环,延迟渲染函数的调用
  }

  if (cb) defer(cb)             // 调用回调函数
  if (_.isFunction(updater)) {  // 处理 setState 后跟函数的情况
    updater = updater(component.state, component.props)
  }
  // 浅合并逻辑
  component.state = Object.assign({}, component.state, updater)
  if (componentArr.includes(component)) {
    component.state = Object.assign({}, component.state, updater)
  } else {
    componentArr.push(component)
  }
}

function render() {
  let component
  while (component = componentArr.shift()) {
    renderComponent(component) // rerender
  }
}

// 事件循环,关于 promise 的事件循环和 setTimeout 的事件循环后续会单独写篇文章。
const defer = function(fn) {
  return Promise.resolve().then(() => fn())
}

此时,每点击一次增加按钮 render 函数只执行一次了。

ref 的实现

在 react 中并不建议使用 ref 属性,而应该尽量使用状态提升,但是 react 还是提供了 ref 属性赋予了开发者操作 dom 的能力,react 的 ref 有 stringcallbackcreateRef 三种形式,分别如下:

// string 这种写法未来会被抛弃
class MyComponent extends Component {
  componentDidMount() {
    this.refs.myRef.focus()
  }
  render() {
    return <input ref="myRef" />
  }
}

// callback(比较通用)
class MyComponent extends Component {
  componentDidMount() {
    this.myRef.focus()
  }
  render() {
    return <input ref={(ele) => {
      this.myRef = ele
    }} />
  }
}

// react 16.3 增加,其它 react-like 框架还没有同步
class MyComponent extends Component {
  constructor() {
    super() {
      this.myRef = React.createRef()
    }
  }
  componentDidMount() {
    this.myRef.current.focus()
  }
  render() {
    return <input ref={this.myRef} />
  }
}

React ref 的前世今生 罗列了三种写法的差异,下面对上述例子中的第二种写法(比较通用)进行实现。

首先在 setAttribute 方法内补充上对 ref 的属性进行特殊处理,

function setAttribute(dom, attr, value) {
  ...
  else if (attr === 'ref') {          // 处理 ref 属性
    if (_.isFunction(value)) {
      value(dom)
    }
  }
  ...
}

针对这个例子中 this.myRef.focus() 的 focus 属性需要异步处理,因为调用 componentDidMount 的时候,界面上还未添加 dom 元素。处理 renderComponent 函数:

function renderComponent(component) {
  ...
  else if (component && component.componentDidMount) {
    defer(component.componentDidMount.bind(component))
  }
  ...
}

刷新页面,可以发现 input 框已为选中状态。

处理完普通元素的 ref 后,再来处理下自定义组件的 ref 的情况。之前默认自定义组件上是没属性的,现在只要针对自定义组件的 ref 属性做相应处理即可。稍微修改 vdomToDom 函数如下:

function vdomToDom(vdom) {
  if (_.isFunction(vdom.nodeName)) { // 此时是自定义组件
    ...
    for (const attr in vdom.attributes) { // 处理自定义组件的 ref 属性
      if (attr === 'ref' && _.isFunction(vdom.attributes[attr])) {
        vdom.attributes[attr](component)
      }
    }
    ...
  }
  ...
}

跑如下测试用例:

class A extends Component {
  constructor() {
    super()
    this.state = {
      count: 0
    }
    this.click = this.click.bind(this)
  }

  click() {
    this.setState({
      count: ++this.state.count
    })
  }

  render() {
    return <div>{this.state.count}</div>
  }
}

class B extends Component {
  constructor() {
    super()
    this.click = this.click.bind(this)
  }

  click() {
    this.A.click()
  }

  render() {
    return (
      <div>
        <button onClick={this.click}>1</button>
        <A ref={(e) => { this.A = e }} />
      </div>
    )
  }
}

效果如下:

鸣谢

Especially thank simple-react for the guidance function of this library. At the meantime,respect for preact and react

@MuYunyun MuYunyun changed the title from 从 0 到 1 实现 React 系列 —— 4.setState优化和ref的实现 to 从 0 到 1 实现 React 系列 —— 4.优化setState和ref的实现 Aug 5, 2018

@yxxuweb

This comment has been minimized.

Show comment
Hide comment
@yxxuweb

yxxuweb Sep 6, 2018

click() {
    for (let i = 0; i < 10; i++) {
      this.setState({
        count: ++this.state.count,
      });
    }

    this.state.count = 13;
    console.log(this.state.count);
  }

我在你的列子上这样调用,UI上面显示是13,而不是this.setState之后的值。

yxxuweb commented Sep 6, 2018

click() {
    for (let i = 0; i < 10; i++) {
      this.setState({
        count: ++this.state.count,
      });
    }

    this.state.count = 13;
    console.log(this.state.count);
  }

我在你的列子上这样调用,UI上面显示是13,而不是this.setState之后的值。

@MuYunyun

This comment has been minimized.

Show comment
Hide comment
@MuYunyun

MuYunyun Sep 6, 2018

Owner

@yxxuweb 针对你反馈的这个测试用例,在 react 和 preact 分别做了实验:

class App extends Component {
  constructor(props) {
    super(props)
    this.state = {
      count: 0
    }
    this.click = this.click.bind(this)
  }

  click() {
    for (let i = 0; i < 10; i++) {
      this.setState({
        count: ++this.state.count
      })
    }

    this.state.count = 13;
  }

  render() {
    return (
      <div>
        <button onClick={this.click}>增加</button>
        <div>{this.state.count}</div>
      </div>
    )
  }
}

在 React 中(点击按钮):

  • UI 上显示为 10。
  • 还有一点是调用完 this.state.count = 13 后,应该调用 this.forceUpdate(),UI 才会生效。

在 cpreact 上表现与 preact 相同

  • UI 上显示为 13。

这个案例在 cpreact 执行流程为:click 事件 => setState 函数 10 次异步调用合并 => this.state.count = 13 => render。因此界面上显示为 13。

但是这么做确实有些违规 react 的法则,直接调用 this.state.count(没有 forceUpdate) 就改变了状态。

react 中的执行流程应该为:click 事件 => setState 函数 10 次异步调用合并 => render => this.state.count = 13

即 react 中 this.state.count 应该放在 render 函数的后面再执行,有点好奇 react 事件循环机制是如何实现的。如果有好的想法欢迎指正。

Owner

MuYunyun commented Sep 6, 2018

@yxxuweb 针对你反馈的这个测试用例,在 react 和 preact 分别做了实验:

class App extends Component {
  constructor(props) {
    super(props)
    this.state = {
      count: 0
    }
    this.click = this.click.bind(this)
  }

  click() {
    for (let i = 0; i < 10; i++) {
      this.setState({
        count: ++this.state.count
      })
    }

    this.state.count = 13;
  }

  render() {
    return (
      <div>
        <button onClick={this.click}>增加</button>
        <div>{this.state.count}</div>
      </div>
    )
  }
}

在 React 中(点击按钮):

  • UI 上显示为 10。
  • 还有一点是调用完 this.state.count = 13 后,应该调用 this.forceUpdate(),UI 才会生效。

在 cpreact 上表现与 preact 相同

  • UI 上显示为 13。

这个案例在 cpreact 执行流程为:click 事件 => setState 函数 10 次异步调用合并 => this.state.count = 13 => render。因此界面上显示为 13。

但是这么做确实有些违规 react 的法则,直接调用 this.state.count(没有 forceUpdate) 就改变了状态。

react 中的执行流程应该为:click 事件 => setState 函数 10 次异步调用合并 => render => this.state.count = 13

即 react 中 this.state.count 应该放在 render 函数的后面再执行,有点好奇 react 事件循环机制是如何实现的。如果有好的想法欢迎指正。

@MuYunyun MuYunyun changed the title from 从 0 到 1 实现 React 系列 —— 4.优化setState和ref的实现 to 4.优化setState和ref的实现 Sep 18, 2018

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment