QQ音樂:React v16 新特性實踐

騰訊雲加社群發表於2018-06-14

歡迎大家前往騰訊雲+社群,獲取更多騰訊海量技術實踐乾貨哦~

本文由QQ音樂技術團隊發表於雲+社群專欄

img

自從去年9月份 React 團隊釋出了 v16.0 版本開始,到18年3月剛釋出的 v16.3 版本,React 陸續推出了多項重磅新特性,並改進了原有功能中反饋呼聲很高的一些問題,例如 render 方法內單節點層級巢狀問題,提供生命週期錯誤捕捉,元件指定 render 到任意 DOM 節點 (Portal) 等能力,以及最新的 Context API 和 Ref API。我們在對以上新特性經過一段時間的使用過後,通過本文進行一些細節分享和總結。

一、render 方法優化

img

為了符合 React 的 component tree 和 diff 結構設計,在元件的 render() 方法中頂層必須包裹為單節點,因此實際元件設計和使用中總是需要注意巢狀後的層級變深,這是 React 的一個經常被人詬病的問題。比如以下的內容結構就必須再巢狀一個 div 使其變為單節點進行返回:

render() {
  return (
    <div>
      注:
      <p>產品說明一</p>
      <p>產品說明二</p>
    </div>
  );
}
複製程式碼

現在在更新 v16 版本後,這個問題有了新的改進,render 方法可以支援返回陣列了:

render() {
  return [
    "注:",
    <p key="t-1">產品說明一</h2>,
    <p key="t-2">產品說明二</h2>,
  ];
}
複製程式碼

這樣確實少了一層,但大家又繼續發現程式碼還是不夠簡潔。首先 TEXT 節點需要用引號包起來,其次由於是陣列,每條內容當然還需要新增逗號分隔,另外 element 上還需要手動加 key 來輔助 diff。給人感覺就是不像在寫 JSX 了。

於是 React v16.2 趁熱打鐵,提供了更直接的方法,就是 Fragment:

render() {
  return (
    <React.Fragment>
      注:        
      <p>產品說明一</p>
      <p>產品說明二</p>
    </React.Fragment>
  );
}
複製程式碼

可以看到是一個正常單節點寫法,直接包裹裡面的內容。但是 Fragment 本身並不會產生真實的 DOM 節點,因此也不會導致層級巢狀增加。

另外 Fragment 還提供了新的 JSX 簡寫方式 <></>:

render() {
  return (
    <>
      注:
      <p>產品說明一</p>
      <p>產品說明二</p>
    </>
  );}
複製程式碼

看上去是否舒服多了。不過注意如果需要給 Fragment 新增 key prop,是不支援使用簡寫的(這也是 Fragment 唯一會遇到需要新增props的情況):

<dl>
  {props.items.map(item => (
    // 要傳key用不了 <></>
    <Fragment key={item.id}>
      <dt>{item.term}</dt>
      <dd>{item.description}</dd>
    </Fragment>
  ))}
</dl>
複製程式碼

二、錯誤邊界 (Error Boundaries)

img

錯誤邊界是指以在元件上定義 componentDidCatch 方法的方式來建立一個有錯誤捕捉功能的元件,在其內巢狀的元件在生命過程中發生的錯誤都會被其捕捉到,而不會上升到外部導致整個頁面和元件樹異常 crash。

例如下面的例子就是通過一個 ErrorBoundary 元件對其內的內容進行保護和錯誤捕捉,並在發生錯誤時進行兜底的UI展示:

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { error: null };
  }
  componentDidCatch(error, 
   {componentStack}
  ) {
    this.setState({
      error,
      componentStack,
    });
  }
  render() {
    if (this.state.error) {
      return (
        <>
          <h1>報錯了.</h1>
          <ErrorPanel {...this.state} />
        </>
      );
    }
    return this.props.children;
  }
}

export default function App(){
  return (
    <ErrorBoundary>
      <Content />
    </ErrorBoundary>
  );
}
複製程式碼

需要注意的是錯誤邊界只能捕捉生命週期中的錯誤 (willMount / render 等方法內)。無法捕捉非同步的、事件回撥中的錯誤,要捕捉和覆蓋所有場景依然需要配合 window.onerror、Promise.catch、 try/catch 等方式。

三、React.createPortal()

img

這個 API 是用來將部分內容分離式地 render 到指定的 DOM 節點上。不同於使用 ReactDom.render 新建立一個 DOM tree 的方式,對於要通過 createPortal() “分離”出去的內容,其間的資料傳遞,生命週期,甚至事件冒泡,依然存在於原本的抽象元件樹結構當中。

class Creater extends Component {
  render(){
    return (
      <div onClick={() => 
        alert("clicked!")
      }>
        <Portal>
          <img src={myImg} />
        </Portal>
      </div>
    ); 
  }
}

class Portal extends Component {
  render(){
    const node = getDOMNode();
    return createPortal(
      this.props.children,
      node 
    ); 
  }
}
複製程式碼

例如以上程式碼, 通過 把裡面的 QQ音樂:React v16 新特性實踐 內容渲染到了一個獨立的節點上。在實際的 DOM 結構中,img 已經脫離了 Creater 本身的 DOM 樹存在於另一個獨立節點。但當點選 img 時,仍然可以神奇的觸發到 Creater 內的 div 上的 onclick 事件。這裡實際依賴於 React 代理和重寫了整套事件系統,讓整個抽象元件樹的邏輯得以保持同步。

四、Context API

img

以前的版本中 Context API 是作為未公開的實驗性功能存在的,隨著越來越多的聲音要求對其進行完善,在 v16.3 版本,React 團隊重新設計併發布了新的官方 Context API。

使用 Context API 可以更方便的在元件中傳遞和共享某些 "全域性" 資料,這是為了解決以往元件間共享公共資料需要通過多餘的 props 進行層層傳遞的問題 (props drilling)。比如以下程式碼:

const HeadTitle = (props) => {
  return (
    <Text>
    {props.lang.title}
    </Text>;
  );
};

// 中間元件
const Head = (props) => {
  return (
    <div>
      <HeadTitle lang={props.lang} />
    </div>
  );
};

class App extends React.Component {
  render() {
    return (
      <Head lang={this.props.lang} />;
    );
  }
}

export default App = connect((state) => {
  return {
    lang:state.lang
  }
})(App);
複製程式碼

我們為了使用一個語言包,把語言配置儲存到一個 store 裡,通過 Redux connect 到頂層元件,然而僅僅是最底端的子元件才需要用到。我們也不可能為每個元件都單獨加上 connect,這會造成資料驅動更新的重複和不可維護。因此中間元件需要一層層不斷傳遞下去,就是所謂的 props drilling。

對於這種全域性、不常修改的資料共享,就比較適合用 Context API 來實現:

首先第一步,類似 store,我們可以先建立一個 Context,並加入預設值:

const LangContext = React.createContext({
  title:"預設標題"
});
複製程式碼

然後在頂層通過 Provider 向元件樹提供 Context 的訪問。這裡可以通過傳入 value 修改 Context 中的資料,當value變化的時候,涉及的 Consumer 內整個內容將重新 render:

class App extends React.Component {
  render() {
    return (
      <LangContext.Provider
        value={this.state.lang}
      >
        <Head />
      </LangContext.Provider>
    );
  }
}
複製程式碼

在需要使用資料的地方,直接用 Context.Consumer 包裹,裡面可以傳入一個 render 函式,執行時從中取得 Context 的資料。

const HeadTitle = (props) => {
  return (
    <LangContext.Consumer>
      {lang => 
        <Text>{lang.title}</Text>
      }
    </LangContext.Consumer>
  );
};
複製程式碼

之後的中間元件也不再需要層層傳遞了,少了很多 props,減少了中間漏傳導致出錯,程式碼也更加清爽:

// 中間元件
const Head = () => {
  return (
    <div>
      <HeadTitle />
    </div>
  );
};
複製程式碼

那麼看了上面的例子,我們是否可以直接使用 Context API 來代替掉所有的資料傳遞,包括去掉 redux 這些資料同步 library 了?其實並不合適。前面也有提到,Context API 應該用於需要全域性共享資料的場景,並且資料最好是不用頻繁更改的。因為作為上層存在的 Context,在資料變化時,容易導致所有涉及的 Consumer 重新 render。

比如下面這個例子:

render() {
  return (
    <Provider value={{
      title:"my title"
    }} >
      <Content />
    </Provider>
  );
}
複製程式碼

實際每次 render 的時候,這裡的 value 都是傳入一個新的物件。這將很容易導致所有的 Consumer 都重新執行 render 影響效能。

因此不建議濫用 Context,對於某些非全域性的業務資料,也不建議作為全域性 Context 放到頂層中共享,以免導致過多的 Context 巢狀和頻繁重新渲染。

五、Ref API

除了 Context API 外,v16.3 還推出了兩個新的 Ref API,用來在元件中更方便的管理和使用 ref。

在此之前先看一下我們之前使用 ref 的兩種方法。

// string命名獲取
componentDidMount(){
  console.log(this.refs.input);
}
render() {
  return (
    <input 
    	ref="input"
    />
  );
}
複製程式碼
// callback 獲取
render() {
  return (
    <input 
    	ref={el => {this.input = el;}}
    />
  );
}
複製程式碼

前一種 string 的方式比較侷限,不方便於多元件間的傳遞或動態獲取。後一種 callback 方法是之前比較推薦的方法。但是寫起來略顯麻煩,而且 update 過程中有發生清除可能會有多次呼叫 (callback 收到 null)。

為了提升易用性,新版本推出了 CreateRef API 來建立一個 ref object, 傳遞到 component 的 ref 上之後可以直接獲得引用:

constructor(props) {
  super(props);
  this.input = React.createRef();
}
componentDidMount() {
  console.log(this.input);
}
render() {
  return <input ref={this.input} />;
}
複製程式碼

另外還提供了 ForwardRef API 來輔助簡化巢狀元件、component 至 element 間的 ref 傳遞,避免出現 this.ref.ref.ref 的問題。

例如我們有一個包裝過的 Button 元件,想獲取裡面真正的 button DOM element,本來需要這樣做:

class MyButton extends Component {
  constructor(props){
    super(props);
    this.buttonRef = React.createRef();
  }
  render(){
    return (
      <button ref={this.buttonRef}>
        {props.children}
      </button>
    );
  }
}
class App extends Component {
  constructor(props){
    super(props);
    this.myRef = React.createRef();
  }
  componentDidComponent{
    // 通過ref一層層訪問
    console.log(this.myRef.buttonRef);
  }
  render(){
    return (
      <MyButton ref={this.myRef}>
        Press here
      </MyButton>
    );
  }
}
複製程式碼

這種場景使用 forwardRef API 的方式做一個“穿透”,就能簡便許多:

import { createRef, forwardRef } from "react";

const MyButton = forwardRef((props, ref) => (
  <button ref={ref}>
    {props.children}
  </button>
));

class App extends Component {
  constructor(props){
    super(props);
    this.realButton = createRef();
  }
  componentDidComponent{
    //直接拿到 inner element ref
    console.log(this.realButton);
  }
  render(){
    return (
    <MyButton ref={this.realButton}>
      Press here
    </MyButton>
    );
  }
}
複製程式碼

總結

以上就是 React v16 釋出以來幾個比較重要和有用的新特性,優化的同時也帶來了開發體驗的提升。另外 v16 對比之前版本還有不錯的包大小降低,也是非常具有優勢的:

img

除此之外,想要了解更多的一些變更比如生命週期的更新 (getDerivedStateFromProps, getSnapshotBeforeUpdate) 和 SSR 的優化 (hydrate),以及即將推出的 React Fiber (async render) 動向,可以點選檢視原文了解更多的官方資訊。

這麼多激動人心的特性,如果你還在用 v15 甚至舊版,就趕快升級體驗吧!


問答

如何從jQuery轉到React.js?

相關閱讀

React Native在全民K歌APP中的使用分享

Android Native 開發之 NewString 與 NewStringUtf 解析

React-Native 分包實踐


此文已由作者授權騰訊雲+社群釋出,原文連結:https://cloud.tencent.com/developer/article/1137778?fromSource=waitui

歡迎大家前往騰訊雲+社群或關注雲加社群微信公眾號(QcloudCommunity),第一時間獲取更多海量技術實踐乾貨哦~

相關文章