React 的行內函數和效能

wznonstop發表於2018-04-02

React 的行內函數和效能

我和妻子近期完成了一次聲勢浩大的裝修。我們迫不及待地想向人們展示我們的新意。我們讓我的婆婆來參觀,她走進那間裝修得很漂亮的臥室,抬頭看了看那扇構造精巧的窗戶,然後說:“居然沒有百葉窗?”?

React 的行內函數和效能

我們的新臥室;天哪,它看起來就像一張雜誌的照片。而且,沒有百葉窗。

我發現,當我談論 React 的時候,會有同樣的情緒。我將通過研討會的第一堂課,展示一些很酷的新特性。總是有人說:“行內函數? 我聽說它們很慢。”

並不總是這樣,但最近幾個月這個觀點每天都會出現。作為一名講師和程式碼庫的作者,這讓人感到精疲力竭。不幸的是,我可能有點傻,之前只知道在 Twitter 上咆哮,而不是去寫一些可能對別人來說有深刻見解的東西。所以,我就來嘗試一下更好的選擇了 ?。

“行內函數”是什麼

在 React 的語境中,行內函數是指在 React 進行 "rendering" 時定義的函式。 人們常常對 React 中 "render" 的兩種含義感到困惑,一種是指在 update 期間從元件中獲取 React 元素(呼叫元件的 render 方法);另一種是渲染更新真實的 DOM 結構。本文中提到的 "rendering"都是指第一種。

下列是一些行內函數的栗子?:

class App extends Component {
  // ...
  render() {
    return (
      <div>
        
        {/* 1. 一個內聯的“DOM元件”事件處理程式 */}
        <button
          onClick={() => {
            this.setState({ clicked: true })
          }}
        >
          Click!
        </button>
        
        {/* 2. 一個“自定義事件”或“操作” */}
        <Sidebar onToggle={(isOpen) => {
          this.setState({ sidebarIsOpen: isOpen })
        }}/>
        
        {/* 3. 一個 render prop 回撥 */}
        <Route
          path="/topic/:id"
          render={({ match }) => (
            <div>
              <h1>{match.params.id}</h1>}
            </div>
          )
        />
      </div>
    )
  }
}
複製程式碼

過早的優化是萬惡之源

在開始下一步之前,我們需要討論一下如何對程式進行優化。詢問任意一個效能方面的專家他們都會告訴你不要過早地優化你的程式。是的,所有具有豐富的效能調優經驗的人,都會告訴你不要過早地優化你的程式碼。

如果你不去進行測量,你甚至不知道你所做的優化是使得程式變好還是變得更糟。

我記得我的朋友 Ralph Holzmann 發表的關於 gzip 如何工作的演講,這個演講鞏固了我對此的看法。他談到了一個他用古老的指令碼載入庫 LABjs 做的實驗。你可以觀看這個視訊的 30:02 到 32:35 來了解它,或者繼續閱讀本文。

當時 LABjs 的原始碼在效能上做了一些令人尷尬的事情。它沒有使用普通的物件表示法(obj.foo),而是將鍵儲存在字串中,並使用方括號表示法來訪問物件(obj[stringForFoo])。這樣做的想法源於,經過小型化和 gzip 壓縮之後,非自然編寫的程式碼將比自然編寫的程式碼體積小。你可以在這裡看到它

Ralph fork 了原始碼,沒有去考慮如何優化以實現小型化 和 gzip,而是通過自然地編寫程式碼移除了優化的部分。

事實證明,移除“優化部分”後,檔案大小削減了 5.3%!如果你不去進行測量,你甚至不知道你所做的優化是使得程式變好還是變得更糟!

過早的優化不僅會佔用開發時間,損害程式碼的整潔,甚至會產生適得其反的結果導致效能問題,就像 LABjs 那樣。如果作者一直在進行測量,而不僅僅是想象效能問題,就會節省開發時間,同時能讓程式碼更簡潔,效能更好。

不要過早地進行優化。好了,回到 React 。

為什麼人們說行內函數很慢?

兩個原因:記憶體/垃圾回收問題和 shouldComponentUpdate

記憶體和垃圾回收

首先,人們(和 eslint configs)擔心建立行內函數產生的記憶體和垃圾回收成本。在箭頭函式普及之前,很多程式碼都會內聯地呼叫 bind ,這在歷史上表現不佳。例如:

<div>
  {stuff.map(function(thing) {
    <div>{thing.whatever}</div>
  }.bind(this)}
</div>
複製程式碼

Function.prototype.bind 的效能問題在此得到了解決,而且箭頭函式要麼是原生函式,要麼是由 Babel 轉換為普通函式;在這兩種情況下,我們都可以假定它並不慢。

記住,你不要坐在那裡然後想象“我賭這個程式碼肯定慢”。你應該自然地編寫程式碼,然後測量它。如果存在效能問題,就修復它們。我們不需要證明一個內聯的箭頭函式是快的,也不需要另一些人來證明它是慢的。否則,這就是一個過早的優化。

據我所知,還沒有人對他們的應用程式進行分析,表明內聯箭頭函式很慢。在進行分析之前,這甚至不值得談論 —— 但無論如何,我會提供一個新思路 ?

如果建立行內函數的成本很高,以至於需要使用 eslint 規則來規避它,那麼我們為什麼要將該開銷轉移到初始化的熱路徑上呢?

class Dashboard extends Component {
  state = { handlingThings: false }
  
  constructor(props) {
    super(props)
    
    this.handleThings = () =>
      this.setState({ handlingThings: true })

    this.handleStuff = () => { /* ... */ }

    // bind 的開銷更昂貴
    this.handleMoreStuff = this.handleMoreStuff.bind(this)
  }

  handleMoreStuff() { /* ... */ }

  render() {
    return (
      <div>
        {this.state.handlingThings ? (
          <div>
            <button onClick={this.handleStuff}/>
            <button onClick={this.handleMoreStuff}/>
          </div>
        ) : (
          <button onClick={this.handleThings}/>
        )}
      </div>
    )
  }
}
複製程式碼

因為過早地優化,我們已經將元件的初始化速度降低了 3 倍!如果所有處理程式都是內聯的,那麼在初始化中只需要建立一個函式。相反的,我們則要建立 3 個。我們沒有測量任何東西,所以沒有理由認為這是一個問題。

如果你想完全忽略這一點,那麼就去制定一個 eslint 規則,來要求在任何地方都使用行內函數來加快初始渲染速度??‍♀。

PureComponent 和 shouldComponentUpdate

這才是問題真正的癥結所在。你可以通過理解兩件事來看到真正的效能提升: shouldComponentUpdate 和 JavaScript 嚴格相等的比較。如果不能很好地理解它們,就可能在無意中以效能優化的名義使 React 程式碼更難處理。

當你呼叫 setState 時,React 會將舊的 React 元素與一組新的 React 元素進行比較(這稱為 r_econciliation_ ,你可以在這裡閱讀相關資料 ),然後使用該資訊更新真實的 DOM 元素。有時候,如果你有很多元素需要檢查,這個過程就會變得很慢(比如一個大的 SVG )。React 為這類情況提供了逃生艙口,名叫 shouldComponentUpdate

class Avatar extends Component {
  shouldComponentUpdate(nextProps, nextState) {
    return stuffChanged(this, nextProps, nextState))
  }
  
  render() {
    return //...
  }
}
複製程式碼

如果你的元件定義了 shouldComponentUpdate ,那麼在 React 進行新舊元素對比之前,它會詢問 shouldComponentUpdate 有沒有變更發生。如果返回了false,那麼React將會直接跳過元素diff檢查,從而節省一些時間。如果你的元件足夠大,這會對效能產生相當大的影響。

優化元件的最常見方法是擴充套件 "React.PureComponent" 而不是 "React.Component" 。一個 PureComponent 會在 shouldComponentUpdate 中比較 props 和 state ,這樣你就不用手動執行了。

class Avatar extends React.PureComponent { ... }
複製程式碼

當被要求更新時,Avatar 會對它的 props 和 state 使用一個嚴格相等比較,希望以此來加快速度。

嚴格相等比較

JavaScript 中有六種基本型別:string, number, boolean, null, undefined, 和 symbol。當你對兩個值相同的基本型別進行“嚴格相等比較”的時候,你會得到一個 true 值。舉個例子?:

const one = 1
const uno = 1
one === uno // true
複製程式碼

PureComponent 比較 props 時,它會使用嚴格相等比較。這對內聯原始值非常有效: <Toggler isOpen={true}/>

prop 的比較只會在有非原始型別們出現的時候產生問題——啊,說錯了,抱歉,是型別而不是型別們。只有一種其他型別,那就是 Object。你問函式和陣列?事實上,它們都是物件(Object)。

函式是具有附加的可呼叫功能的常規物件。

哈哈哈,不愧是 JavaScript。無論如何,對物件使用嚴格相等檢查,即使表面上看起來相等的值,也會被判定為 false(不相等):

const one = { n: 1 }
const uno = { n: 1 }
one === uno // false
one === one // true
複製程式碼

所以,如果你在 JSX 中內聯地使用一個物件,它會使 PureComponent 的 prop diff 檢查失效,轉而使用較昂貴的方式對 React 元素進行 diff 檢查。元素的 diff 將變為空,這樣就浪費了兩次進行差異比較的時間。

// 第一次 render
<Avatar user={{ id: 'ryan' }}/>

// 下一次 render
<Avatar user={{ id: 'ryan' }}/>

// prop diff 認為有東西發生了變化,因為 {} !== {}
// 元素 diff 檢查 (reconciler) 發現沒有任何變化
複製程式碼

由於函式是物件,而且 PureComponent 會對 props 進行嚴格相等的檢查,因此,一個內聯的函式將總是無法通過 prop 的 diff 檢查,從而轉向 reconciler 中的元素 diff 檢查。

可以看出,這不僅僅只關乎行內函數。函式簡直就是 object, function, array 三部曲演繹推廣的主唱。

為了讓 shouldComponentUpdate 高興,你必須保持函式的引用標識。對經驗豐富的 JavaScript 開發者來說,這不算糟。但是 Michael 和我領導了一個有3500多人蔘加的研討會,他們的開發經驗各不相同,而這對很多人來說都並不容易。ES 的類也沒有提供引導我們進入各種 JavaScript 路徑的幫助:

class Dashboard extends Component {
  constructor(props) {
    super(props)
    
    // 使用 bind ?拖慢初始化的速度,看上去不妙
    // 當你有 20 個 bind 的時候(我見過你的程式碼,我知道)
    // 它會增加打包後檔案的大小
    this.handleStuff = this.handleStuff.bind(this)

    // _this 一點也不優雅
    var _this = this
    this.handleStuff = function() {
      _this.setState({})
    }
    
    // 如果你會用 ES 的類,那你很可能會使用箭頭
    // 函式(通過 babel ,或使用現代瀏覽器)。這不是很難但是
    // 把你所有的處理程式都放在建構函式中就
    // 不太好了
    this.handleStuff = () => {
      this.setState({})
    }
  }
  
  // 這個很不錯,但它不是 JavaScript ,至少現在還不是,所以現在
  // 我們要討論的是 TC39 如何工作,並評估我們的草案
  // 階段風險容忍度
  handleStuff = () => {}
}
複製程式碼

學習如何保持函式的引用標識將會引出一個令人驚訝的長篇大論。

通常沒有理由強迫人們這麼做,除非有一個 eslint 配置對他們大喊大叫。我想展示的是,行內函數和提升效能兩者可以兼得。但首先,我想講一個我自己遇到的效能相關的故事。

我使用 PureComponent 的經歷

當我第一次瞭解到 PureRenderMixin(在 React 的早期版本中叫這個,後來改為 PureComponent )時,我進行了大量的測試,來測試我的應用程式的效能。然後,我將 PureRenderMixin 新增到每個元件中。當我採取了一套優化後的測量方法時,我希望有一個關於一切變得有多快的很酷的故事可以講。

讓人大跌眼鏡的是,我的應用程式變慢了 ?。

為什麼呢?仔細想想,如果你有一個 Component ,會有多少次 diff 檢查?如果你有一個 PureComponent ,又會有多少次 diff 檢查?答案分別是“只有一次”和“至少一次,有時是兩次”。如果一個元件經常在更新時發生變化,那麼 PureComponent 將會執行兩次 diff 檢查而不是一次(props 和 state 在 shouldComponentUpdate 中進行的嚴格相等比較,以及常規的元素 diff 檢查)。這意味著通常它會變慢,偶爾會變快。顯然,我的大部分元件大部分時間都在變化,所以總的來說,我的應用程式變慢了。啊哦?。

在效能方面沒有銀彈。你必須測量。

三種情景

在本文的開頭,我展示了三種行內函數。現在我們已經瞭解了一些背景,讓我們來一一討論一下它們。但是請記住,在你有一個衡量標準來判定之前,請先將 PureComponent 束之高閣。

DOM 元件事件處理程式

<button
  onClick={() => this.setState(…)}
>click</button>
複製程式碼

通常,在 buttons,inputs,和其他 DOM 元件的事件處理程式中,除了 setState 以外,不會做其他的事情。這讓行內函數成為了通常情況下最乾淨的方法。它們不是在檔案中跳來跳去尋找事件處理程式,而是把內容放在同一位置。React 社群通常歡迎這種方式。

button 元件(以及所有其他的DOM元件)甚至都算不上是 PureComponent,所以這裡也不存在 shouldComponentUpdate 引用標識的問題。

所以,認為這個過程很慢的唯一原因是,你是否認為簡單地定義一個函式會產生足以讓人擔心的開銷。我們已經討論過,這在任何地方都未被證實。這只是紙上談兵的效能假設。在被證實之前,這樣做沒問題。

一個“自定義事件”或“操作”

<Sidebar onToggle={(isOpen) => {
  this.setState({ sidebarIsOpen: isOpen })
}}/>
複製程式碼

如果 SidebarPureComponent,我們將會打破 prop 的 diff 檢查。再一次,由於處理程式很簡單,最好把它們都放在同一位置。

對於像 onToggle 這樣的事件,Sidebar 還有什麼必要對它執行 diff 檢查呢?只有兩種情況才需要將 prop 包含在 shouldComponentUpdate 的 diff 檢查中:

  1. 你使用 prop 來進行渲染
  2. 你使用 prop 來在 componentWillReceivePropscomponentDidUpdate,或者 componentWillUpdate 中產生一些其他的作用

大多數 on<whatever> prop 都不符合這些要求。因此,多數 PureComponent 的用法都會導致多次執行 diff 檢查,迫使開發人員不必要地維護處理程式的引用標識。

我們只應該對會產生影響的 prop 執行 diff 檢查。這樣,人們就可以將處理程式放在同一位置,並且仍然可以獲得想要尋求的效能提升(而且由於我們關心效能,所以我們希望執行更少次數的 diff 檢查!)

對於大多陣列件,我建議建立一個 PureComponentMinusHandlers 類並從中繼承,而不是從 PureComponent 中繼承。它可以跳過對函式的所有檢查。魚與熊掌兼得。

好吧,差不多是這樣的。

如果你接收到一個函式並直接將它傳遞給另一個元件,它將會無法及時更新。看一下這個:

// 1. App 會傳遞一個 prop 給 From 表單
// 2. Form 將向下傳遞一個函式給 button
//    這個函式與它從 App 得到的 prop 相接近
// 3. App 會在 mounting 之後 setState,並傳遞
//    一個**新**的 prop 給 Form
// 4. Form 傳遞一個新的函式給 Button,這個函式與
//    新的 prop 相接近
// 5. Button 會忽略新的函式, 並無法
//    更新點選處理程式,從而提交陳舊的資料

class App extends React.Component {
  state = { val: "one" }

  componentDidMount() {
    this.setState({ val: "two" })
  }

  render() {
    return <Form value={this.state.val} />
  }
}

const Form = props => (
  <Button
    onClick={() => {
      submit(props.value)
    }}
  />
)

class Button extends React.Component {
  shouldComponentUpdate() {
    // 讓我們假裝比較了除函式以外的一切東西
    return false
  }

  handleClick = () => this.props.onClick()

  render() {
    return (
      <div>
        <button onClick={this.props.onClick}>這個的資料是舊的</button>
        <button onClick={() => this.props.onClick()}>這個工作正常</button>
        <button onClick={this.handleClick}>這個也工作正常</button>
      </div>
    )
  }
}
複製程式碼

這是一個執行該應用程式的沙箱

因此,如果你喜歡從 PureRenderWithoutHandlers 繼承的想法,請確保永遠不要將你要在 diff 檢查中要忽略的處理程式直接傳遞給其他元件——你需要以某種方式包裝它們。

現在,我們要麼必須維護引用標識,要麼必須避免引用標識!歡迎來到效能優化。至少在這種方法中,必須處理的是優化元件,而不是使用它的程式碼。

我要坦率地說,這個示例應用程式是我在釋出 Andrew Clark 後所做的編輯,它引起了我的注意。在這裡,您認為我足夠聰明,知道什麼時候管理引用標識,什麼時候不管理了吧!?

一個 render prop

<Route
  path="/topic/:id"
  render={({ match }) => (
    <div>
      <h1>{match.params.id}</h1>}
    </div>
  )
/>
複製程式碼

用來渲染的 prop 是一種模式,它用來建立一個用於組成和管理共享狀態的元件。(你可以在這裡瞭解更多)。它的內容對元件來說是未知的,舉個栗子?:

const App = (props) => (
  <div>
    <h1>Welcome, {props.name}</h1>
    <Route path="/" render={() => (
      <div>
        {/*
          prop.name 是從路由外部傳入的,它不是作為 prop 傳遞進來的,
          因此路由不能可靠地成為一個PureComponent,它
          不知道在元件內部會渲染什麼
        */}
        <h1>Hey, {props.name}, let's get started!</h1>
      </div>
    )}/>
  </div>
)
複製程式碼

這意味著一個內聯的用來渲染的 prop 函式不會導致 shouldComponentUpdate 的問題:它永遠沒有足夠的資訊來成為一個 PureComponent

所以,唯一的反對意見又回到了相信簡單地定義一個函式是緩慢的。重複第一個例子:沒有證據支援這一觀點。這只是紙上談兵的效能假設。

React 的行內函數和效能

總結

  1. 自然地編寫程式碼,設計程式碼
  2. 測量你的互動,找到慢在哪裡。這裡是方法.
  3. 僅在需要的時候使用 PureComponentshouldComponentUpdate,避免使用 prop 函式(除非它們在生命週期的鉤子函式中為產生某種作用而使用)。

如果你真的相信過早的優化不是好主意,那麼你就不需要證明行內函數是快的,而是需要證明它們是慢的。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章