從一次react異步setState引發的思考

一個異步請求,當請求返回的時候,拿到數據馬上setState並把loading組件換掉,很常規的操作。但是,當那個需要setState的組件被卸載的時候(切換路由、卸載上一個狀態組件)去setState就會警告:

從一次react異步setState引發的思考
從一次react異步setState引發的思考

於是,一個很簡單的方法也來了:

// 掛載
componentDidMount() {
  this._isMounted = true;
}

// 卸載
componentWillUnmount() {
   this._isMounted = false;
}

// 請求
request(url)
.then(res => {
  if (this._isMounted) {
    this.setState(...)
  }
})
複製代碼

問題fix。

1. 不想一個個改了

項目肯定不是簡簡單單的,如果要考慮,所有的異步setState都要改,改到何年何日。最簡單的方法,換用preact,它內部已經考慮到這個case,封裝了這些方法,隨便用。或者console它的組件this,有一個 __reactstandin__isMounted 的屬性,這個就是我們想要的 _isMounted

不過,項目可能不是説改技術棧就改的,我們只能回到原來的react項目中。不想一個個搞,那我們直接改原生的生命週期和setState吧。

// 我們讓setState更加安全,叫他safe吧
function safe(setState, ctx) {
  console.log(ctx, 666);
  return (...args) => {
    if (ctx._isMounted) {
      setState.bind(ctx)(...args);
    }
  } 
}

// 在構造函數裏面做一下處理
constructor() {
  super();
  this.setState = a(this.setState, this);
}

// 掛載
componentDidMount() {
  this._isMounted = true;
}

// 卸載
componentWillUnmount() {
   this._isMounted = false;
}
複製代碼

2. 不想直接改

直接在構造函數裏面改,顯得有點耍流氓,而且不夠優雅。本着代碼優雅的目的,很自然地就想到了裝飾器 @ 。如果項目的babel不支持的,安裝 babel-plugin-transform-decorators-legacy ,加入babel的配置中:

"plugins": [
      "transform-decorators-legacy"
    ]
複製代碼

考慮到很多人用了 create-react-app ,這個腳手架原本不支持裝飾器,需要我們修改配置。使用命令 npm run eject 可以彈出個性化配置,這個過程不可逆,於是就到了webpack的配置了。如果我們不想彈出個性化配置,也可以找到它的配置文檔: node_modules => babel-preset-react-app => create.js ,在plugin數組加上 require.resolve('babel-plugin-transform-decorators-legacy') 再重新啟動項目即可。

回到正題,如果想優雅一點,每一個想改的地方不用寫太多代碼,想改就改,那麼可以加上一個裝飾器給組件:

function safe(_target_) {
  const target = _target_.prototype;
  const {
    componentDidMount,
    componentWillUnmount,
    setState,
  } = target;
  target.componentDidMount = () => {
    componentDidMount.call(target);
    target._isMounted = true;
  }

  target.componentWillUnmount = () => {
    componentWillUnmount.call(target);
    target._isMounted = false;
  }

  target.setState = (...args) => {
    if (target._isMounted) {
      setState.call(target, ...args);
    }
  } 
}

@safe
export default class Test extends Component {
 // ...
}
複製代碼

這樣子,就封裝了一個這樣的組件,對一個被卸載的組件setstate的時候並不會警告和報錯。

但是需要注意的是,我們裝飾的只是一個類,所以類的實例的this是拿不到的。在上面被改寫過的函數有依賴this.state或者props的就導致報錯,直接修飾構造函數以外的函數實際上是修飾原型鏈,而構造函數也不可以被修飾,這些都是沒意義的而且讓你頁面全面崩盤。所以,最完美的還是直接在constructor裏面修改this.xx,這樣子實例化的對象this就可以拿到,然後給實例加上生命週期。

// 構造函數裏面
    this.setState = safes(this.setState, this);
    this.componentDidMount = did(this.componentDidMount, this)
    this.componentWillUnmount = will(this.componentWillUnmount, this)

// 修飾器
function safes(setState, ctx) {
  return (...args) => {
    if (ctx._isMounted) {
      setState.bind(ctx)(...args);
    }
  } 
}
function did(didm, ctx) {
  return(...args) => {
    ctx._isMounted = true;
    didm.call(ctx);
  }
}
function will(willu, ctx) {
  return (...args) => {
    ctx._isMounted = false;
    willu.call(ctx);
  } 
}
複製代碼

3. 添加業務生命週期

我們來玩一點更刺激的——給state賦值。

平時,有一些場景,props下來的都是後台數據,可能你在前面一層組件處理過,可能你在constructor裏面處理,也可能在render裏面處理。比如,傳入1至12數字,代表一年級到高三;後台給stringify過的對象但你需要操作對象本身等等。有n種方法處理數據,如果多個人開發,可能就亂了,畢竟大家風格不一樣。是不是想過有一個beforeRender方法,在render之前處理一波數據,render後再把它改回去。

// 首先函數在構造函數裏面改一波
this.render = render(this.render, this);

// 然後修飾器,我們希望beforeRender在render前面發生
function render(_render, ctx) {
  return function() {
    ctx.beforeRender && ctx.beforeRender.call(ctx);
    const r = _render.call(ctx);
    return r;
  }
} 

// 接着就是用的問題
constructor() {
    super()
    this.state = {
      a: 1
    }
  this.render = render(this.render, this);
}
  beforeRender() {
    this._state_ = { ...this.state };
    this.state.a += 100;
  }

  render() {
    return (
      <div>
        {this.state.a}
      </div>
    )
  }
複製代碼

我們可以看見輸出的是101。改過人家的東西,那就得改回去,不然就是101了,你肯定不希望這樣子。didmount或者didupdate是可以搞定,但是需要你自己寫。我們可以再封裝一波,在背後悄悄進行:

// 加上render之後的操作:
function render(_render, ctx) {
  return function(...args) {
    ctx.beforeRender && ctx.beforeRender.call(ctx);
    const r = _render.call(ctx);
    // 這裏只是一層對象淺遍歷賦值,實際上需要考慮深度遍歷
    Object.keys(ctx._state_).forEach(k => {
      ctx.state[k] = ctx._state_[k];
    })
    return r;
  }
} 
複製代碼

一個很重要的問題,千萬不要 this.state = this._state_ ,比如你前面的didmount在幾秒後打印this.state,它還是原來的state。因為那時候持有對原state對象的引用,後來你賦值只是改變以後state的引用,對於前面的dimount是沒意義的。

// 補上componentDidMount可以測試一波
  componentDidMount() {
    setTimeout(() => {
      this.setState({ a: 2 })
    }, 500);
    setTimeout(() => {
      console.log(this.state.a, '5秒結果') // 要是前面的還原是this.state = this._state_,這裏還是101
    }, 5000);
  }
複製代碼

當然,這些都是突發奇想的。考慮性能與深度遍歷以及擴展性,還是有挺多優化的地方,什麼時候要深度遍歷,什麼時候要賦值,什麼時候可以換一種姿勢遍歷或者什麼時候完全不用遍歷,這些都是設計需要思考的點。