為什麼 JSX 語法這麼香?

語言: CN / TW / HK

theme: fancy highlight: tomorrow-night-blue


持續創作,加速成長!這是我參與「掘金日新計劃 · 6 月更文挑戰」的第9天,點選檢視活動詳情

前言

時下雖然接入 JSX 語法的框架(React、Vue)越來越多,但與之緣分最深的毫無疑問仍然是 React。2013 年,當 React 帶著 JSX 橫空出世時,社群曾對 JSX 有過不少的爭議,但如今,越來越多的人面對 JSX 都要說上一句“真香”!典型的“真香”系列。

JSX 是什麼?

按照 React 官方的解釋,JSX 是一個 JavaScript 的語法擴充套件,類似於模板語法,或者說是一個類似於 XML 的 ECMAScript 語法擴充套件,並且具備 JavaScript 的全部功能。

這段解釋可抽離兩個關鍵點:

  • 「JavaScript 語法擴充套件」
  • 「具備JavaScript 的全部功能」

JSX 的定位是 JavaScript 的「語法擴充套件」,而不是“某個版本”,這就決定了瀏覽器並不會像天然支援 JavaScript 一樣支援 JSX 。這就引出了一個問題 “JSX 是如何在 JavaScript 中生效的?”

JSX 語法是如何在 JavaScript 中生效的?

React

在 React 框架中,JSX 的語法是如何在 JavaScript 中生效的呢?React 官網給出的解釋是,JSX 會被編譯為 React.createElement(), React.createElement() 將返回一個叫作“React Element”的 JS 物件

對於 JSX 的編譯是由 Babel 來完成的。

Babel 是一個工具鏈,主要用於將採用 ECMAScript 2015+ 語法編寫的程式碼轉換為向後相容的 JavaScript 語法,以便能夠執行在當前和舊版本的瀏覽器或其他環境中。

當然 Babel 也具備將 JSX 轉換為 JS 的能力,看一個例子:左邊是我們 React 開發中寫到的語法,並且包含了一段 JSX 程式碼。經過 Babel 轉換之後,就全部變成了 JS 程式碼。

其實如果仔細看,發現 JSX 更像是一種語法糖,通過類似模板語法的描述方式,描述函式物件。其實在 React 中並不會強制使用 JSX 語法,我們也可以使用 React.createElement 函式,例如使用 React.createElement 函式寫這樣一段程式碼。

```js class Test extends React.Component { render() { return React.createElement( "div", null, React.createElement( "div", null, "Hello, ", this.props.test ), React.createElement("div", null, "Today is a fine day.") ); } }

ReactDOM.render( React.createElement(Test, { test: "baixiaobai" }), document.getElementById("root") ); ```

在採用 JSX 之後,這段程式碼會這樣寫:

js class Test extends React.Component { render() { return ( <div> <div>Hello, {this.props.test}</div> <div>Today is a fine day.</div> </div> ); } } ReactDOM.render( <Test test="baixiaobai" />, document.getElementById('root') );

通過對比發現,在實際功能效果一致的前提下,JSX 程式碼層次分明、巢狀關係清晰;而 React.createElement 程式碼則給人一種非常混亂的“雜糅感”,這樣的程式碼不僅讀起來不友好,寫起來也費勁。

JSX 語法寫出來的程式碼更為的簡潔,而且程式碼結構層次更加的清晰。

JSX 語法糖允許我們開發人員像寫 HTML 一樣來寫我們的 JS 程式碼。在降低學習成本的同時還提升了我們的研發效率和研發體驗。

Vue

當然在 Vue 框架中也不例外的可以使用 JSX 語法,雖然 Vue 預設推薦的還是模板。

為什麼預設推薦的模板語法,引用一段 Vue 官網的原話如下:

任何合乎規範的 HTML 都是合法的 Vue 模板,這也帶來了一些特有的優勢:

  • 對於很多習慣了 HTML 的開發者來說,模板比起 JSX 讀寫起來更自然。這裡當然有主觀偏好的成分,但如果這種區別會導致開發效率的提升,那麼它就有客觀的價值存在。
  • 基於 HTML 的模板使得將已有的應用逐步遷移到 Vue 更為容易。
  • 這也使得設計師和新人開發者更容易理解和參與到專案中。
  • 你甚至可以使用其他模板前處理器,比如 Pug 來書寫 Vue 的模板。

有些開發者認為模板意味著需要學習額外的 DSL (Domain-Specific Language 領域特定語言) 才能進行開發——我們認為這種區別是比較膚淺的。首先,JSX 並不是沒有學習成本的——它是基於 JS 之上的一套額外語法。同時,正如同熟悉 JS 的人學習 JSX 會很容易一樣,熟悉 HTML 的人學習 Vue 的模板語法也是很容易的。最後,DSL 的存在使得我們可以讓開發者用更少的程式碼做更多的事,比如 v-on 的各種修飾符,在 JSX 中實現對應的功能會需要多得多的程式碼。

更抽象一點來看,我們可以把元件區分為兩類:一類是偏視圖表現的 (presentational),一類則是偏邏輯的 (logical)。我們推薦在前者中使用模板,在後者中使用 JSX 或渲染函式。這兩類元件的比例會根據應用型別的不同有所變化,但整體來說我們發現表現類的元件遠遠多於邏輯類元件。

例如有這樣一段模板語法。

js <anchored-heading :level="1"> <span>Hello</span> world! </anchored-heading>

使用 JSX 語法會寫成這樣。

js render: function (h) { return ( <AnchoredHeading level={1}> <span>Hello</span> world! </AnchoredHeading> ) }

轉換為 createElement 轉換的 JS 就變成了這樣。

js createElement( 'anchored-heading', { props: { level: 1 } }, [ createElement('span', 'Hello'), ' world!' ] );

但是不管是模板語法還是 JSX 語法,都不會得到瀏覽器純天然的支援,這些語法最後都會被編譯成相應的 h 函式(createElement函式,不泛指所有版本,在不同版本有差異)最後變成 JS 物件,這裡的編譯也是和 React 一樣使用的 Babel 外掛來完成的。

不管是 React 推崇的 JSX 語法,還是 Vue 預設的模板語法,目的都是為了讓我們寫出來的程式碼更為的簡潔,而且程式碼介面層次更加的清晰。在降低學習成本的同時還提升了我們的研發效率和研發體驗。

讀到這裡,相信你已經充分理解了“JSX 是 JavaScript 的一種語法擴充套件,它和模板語言很接近,並且具備 JavaScript 的全部功能。 ”這一定義背後的深意。

不管是 React 還是 Vue 我們都提到了一個函式 createElement,這個函式就是將我們的 JSX 對映為 DOM的。

JSX 是如何對映為 DOM 的:起底 createElement 原始碼

對於 creatElement 原始碼的分析,我們也分 React 和 Vue 來為大家解讀。

原始碼分析的具體版本沒有必要去過於詳細的討論,因為不管是 React 還是 Vue 對於在實現 createElement 上在不同版本差別不大。

React

```js export function createElement(type, config, children) { // propName 變數用於儲存後面需要用到的元素屬性 let propName; // props 變數用於儲存元素屬性的鍵值對集合 const props = {}; // key、ref、self、source 均為 React 元素的屬性,此處不必深究 let key = null; let ref = null; let self = null; let source = null; // config 物件中儲存的是元素的屬性 if (config != null) { // 進來之後做的第一件事,是依次對 ref、key、self 和 source 屬性賦值 if (hasValidRef(config)) { ref = config.ref; } // 此處將 key 值字串化 if (hasValidKey(config)) { key = '' + config.key; } self = config.__self === undefined ? null : config.__self; source = config.__source === undefined ? null : config.__source; // 接著就是要把 config 裡面的屬性都一個一個挪到 props 這個之前宣告好的物件裡面 for (propName in config) { if ( // 篩選出可以提進 props 物件裡的屬性 hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName) ) { props[propName] = config[propName]; } } } // childrenLength 指的是當前元素的子元素的個數,減去的 2 是 type 和 config 兩個引數佔用的長度 const childrenLength = arguments.length - 2; // 如果拋去type和config,就只剩下一個引數,一般意味著文字節點出現了 if (childrenLength === 1) { // 直接把這個引數的值賦給props.children props.children = children; // 處理巢狀多個子元素的情況 } else if (childrenLength > 1) { // 宣告一個子元素陣列 const childArray = Array(childrenLength); // 把子元素推進數組裡 for (let i = 0; i < childrenLength; i++) { childArray[i] = arguments[i + 2]; } // 最後把這個陣列賦值給props.children props.children = childArray; }

// 處理 defaultProps if (type && type.defaultProps) { const defaultProps = type.defaultProps; for (propName in defaultProps) { if (props[propName] === undefined) { props[propName] = defaultProps[propName]; } } } // 最後返回一個呼叫ReactElement執行方法,並傳入剛才處理過的引數 return ReactElement( type, key, ref, self, source, ReactCurrentOwner.current, props, ); } ```

createElement 函式有 3 個入參,這 3 個入參包含了我們在建立一個 React 元素的全部資訊。

  • type:用於標識節點的型別。可以是原生態的 div 、span 這樣的 HTML 標籤,也可以是 React 元件,還可以是 React fragment(空元素)。
  • config:一個物件,元件所有的屬性(不包含預設的一些屬性)都會以鍵值對的形式儲存在 config 物件中。
  • children:泛指第二個引數後的所有引數,它記錄的是元件標籤之間巢狀的內容,也就是所謂的“子節點”“子元素”。

從原始碼角度來看,createElement 函式就是將開發時研發人員寫的資料、屬性、引數做一層格式化,轉化為 React 好理解的引數,然後交付給 ReactElement 來實現元素建立。

接下來我們來看看 ReactElement 函式

```js const ReactElement = function(type, key, ref, self, source, owner, props) { const element = { // 標記這是個 React Element $$typeof: REACT_ELEMENT_TYPE,

type: type,
key: key,
ref: ref,
props: props,
_owner: owner,

};

return element; }; ```

原始碼異常的簡單,也就是對 createElement 函式轉換的引數,在進行一次處理,包裝進 element 物件中返給開發者。如果你試過將這個返回 ReactElement 進行輸出,你會發現有沒有很熟悉的感覺,沒錯,這就是我們老生常談的「虛擬 DOM」,JavaScript 物件對 DOM 的描述。

最後通過 ReactDOM.render 方法將虛擬DOM 渲染到指定的容器裡面。

Vue

Vue 2

我們在來看看 Vue 是如何對映 DOM 的。

js export function createElement ( context: Component, tag: any, data: any, children: any, normalizationType: any, alwaysNormalize: boolean ): VNode | Array<VNode> { ... return _createElement(context, tag, data, children, normalizationType) }

createElement 函式就是對 _createElement 函式的一個封裝,它允許傳入的引數更加靈活,在處理這些引數後,呼叫真正建立 VNode 的函式 _createElement:

js export function _createElement ( context: Component, tag?: string | Class<Component> | Function | Object, data?: VNodeData, children?: any, normalizationType?: number ): VNode | Array<VNode> { ... return vnode; }

_createElement 方法有 5 個引數:

  • context 表示 VNode 的上下文環境。
  • tag 表示標籤,它可以是一個字串,也可以是一個 Component。
  • data 表示 VNode 的資料。
  • children 表示當前 VNode 的子節點,它是任意型別的,它接下來需要被規範為標準的 VNode 陣列。
  • normalizationType 表示子節點規範的型別,型別不同規範的方法也就不一樣,它主要是參考 render 函式是編譯生成的還是使用者手寫的。

_createElement 實現內容略多,這裡就不詳細分析了,反正最後都會建立一個 VNode ,每個 VNode 有 children,children 每個元素也是一個 VNode,這樣就形成了一個 VNode Tree,它很好的描述了我們的 DOM Tree。

當 VNode 建立好之後,就下來就是把 VNode 渲染成一個真實的 DOM 並渲染出來。這個過程是通過 vm._update 完成的。Vue 的 _update 是例項的一個私有方法,它被呼叫的時機有 2 個,一個是首次渲染,一個是資料更新的時候,我們這裡只看首次渲染;當呼叫 _update 時,核心就是呼叫 vm.patch 方法。

patch:這個方法實際上在不同的平臺,比如 web 和 weex 上的定義是不一樣的

引入一段程式碼來看看具體實現。

js var app = new Vue({ el: '#app', render: function (createElement) { return createElement('div', { attrs: { id: 'app' }, }, this.message) }, data: { message: 'Hello Vue!' } });

在 vm._update 的方法裡是這麼呼叫 patch 方法的:

js if (!prevVnode) { // 首次渲染 vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */); } else { // 更新 vm.$el = vm.__patch__(prevVnode, vnode); }

首次渲染:

  • $el 對應的就是 id 為 app 的 DOM 元素。
  • vnode 對應的是 render 函式通過 createElement 函式建立的 虛擬 DOM。
  • hydrating 在非服務端渲染情況下為 false。

確認首次渲染的引數之後,我們再來看看 patch 的執行過程。一段又臭又長的原始碼。

```js function patch (oldVnode, vnode, hydrating, removeOnly) { if (isUndef(vnode)) { if (isDef(oldVnode)) { invokeDestroyHook(oldVnode); } return }

  var isInitialPatch = false;
  var insertedVnodeQueue = [];

  if (isUndef(oldVnode)) {
    // empty mount (likely as component), create new root element
    isInitialPatch = true;
    createElm(vnode, insertedVnodeQueue);
  } else {
    var isRealElement = isDef(oldVnode.nodeType);
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // patch existing root node
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
    } else {
      if (isRealElement) {
        // mounting to a real element
        // check if this is server-rendered content and if we can perform
        // a successful hydration.
        if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
          oldVnode.removeAttribute(SSR_ATTR);
          hydrating = true;
        }
        if (isTrue(hydrating)) {
          if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
            invokeInsertHook(vnode, insertedVnodeQueue, true);
            return oldVnode
          } else {
            warn(
              'The client-side rendered virtual DOM tree is not matching ' +
              'server-rendered content. This is likely caused by incorrect ' +
              'HTML markup, for example nesting block-level elements inside ' +
              '<p>, or missing <tbody>. Bailing hydration and performing ' +
              'full client-side render.'
            );
          }
        }
        // either not server-rendered, or hydration failed.
        // create an empty node and replace it
        oldVnode = emptyNodeAt(oldVnode);
      }

      // replacing existing element
      var oldElm = oldVnode.elm;
      var parentElm = nodeOps.parentNode(oldElm);

      // create new node
      createElm(
        vnode,
        insertedVnodeQueue,
        // extremely rare edge case: do not insert if old element is in a
        // leaving transition. Only happens when combining transition +
        // keep-alive + HOCs. (#4590)
        oldElm._leaveCb ? null : parentElm,
        nodeOps.nextSibling(oldElm)
      );

      // update parent placeholder node element, recursively
      if (isDef(vnode.parent)) {
        var ancestor = vnode.parent;
        var patchable = isPatchable(vnode);
        while (ancestor) {
          for (var i = 0; i < cbs.destroy.length; ++i) {
            cbs.destroy[i](ancestor);
          }
          ancestor.elm = vnode.elm;
          if (patchable) {
            for (var i$1 = 0; i$1 < cbs.create.length; ++i$1) {
              cbs.create[i$1](emptyNode, ancestor);
            }
            // #6513
            // invoke insert hooks that may have been merged by create hooks.
            // e.g. for directives that uses the "inserted" hook.
            var insert = ancestor.data.hook.insert;
            if (insert.merged) {
              // start at index 1 to avoid re-invoking component mounted hook
              for (var i$2 = 1; i$2 < insert.fns.length; i$2++) {
                insert.fns[i$2]();
              }
            }
          } else {
            registerRef(ancestor);
          }
          ancestor = ancestor.parent;
        }
      }

      // destroy old node
      if (isDef(parentElm)) {
        removeVnodes([oldVnode], 0, 0);
      } else if (isDef(oldVnode.tag)) {
        invokeDestroyHook(oldVnode);
      }
    }
  }

  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
  return vnode.elm
}

```

在首次渲染時,由於我們傳入的 oldVnode( id 為 app 的 DOM 元素 ) 實際上是一個 DOM container,接下來又通過 emptyNodeAt 方法把 oldVnode 轉換成 VNode 物件,然後再呼叫 createElm 方法,通過虛擬節點建立真實的 DOM 並插入到它的父節點中。

通過起底 React 和 Vue 的 createElement 原始碼,分析了 JSX 是如何對映為真實 DOM 的,實現思路的整體方向都是一樣的。所以說優秀的框架大家都在相互借鑑,相互學習。

為什麼 React 一開始就選擇 JSX?

在 2013 年,React 帶著 JSX 語法出現,剛出現時飽受爭議,為什麼 React 會選擇 JSX?而不是其他的語法。比如:

模板

模板語法比較典型的是 AngularJS,如果你用過 AngularJS,你會發現對於模板會引入很多的概念,比如新的模板語法、新的模板指令。

```js

Hello





angular.module('test', []) .controller('Ctrl1', function Ctrl1($scope) { $scope.name = '1'; }); ```

React 的設計初衷是「關注點分離」,React 本身的關注基本單位是元件,在元件內部高內聚,元件之間低耦合。而模板語法做不到。並且 JSX 並不會引入太多的新的概念。 也可以看出 React 程式碼更簡潔,更具有可讀性,更貼近 HTML。

js const App = (props) => { return ( <div> xxx </div> ) }

模板字串

JSX 的語法淺看有一點像模板字串,如果在早幾年,使用過 PHP + JQuery 技術棧的同學可能寫過類似這樣語法的程式碼。

``js var box = jsx <${Box}> ${ true ? jsx<${Box.Comment}> Text Content <!--${Box.Comment}--> : jsx<${Box.Comment}> Text Content <!--${Box.Comment}--> }

`; ```

不知你怎麼看,反正我當時在寫這樣程式碼的時候是很痛苦的,並且程式碼結果變得更加複雜,不利於後期的維護。

JXON

```js 1111

```

但最終放棄 JXON 這一方案的原因是,大括號不能為元素在樹中開始和結束的位置,提供很好的語法提示。

template

```js