為什麼 JSX 語法這麼香?
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
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
```
但最終放棄 JXON 這一方案的原因是,大括號不能為元素在樹中開始和結束的位置,提供很好的語法提示。
template
```js
- 面試小技巧:如果有人問你 xxx 技術是什麼?
- Shopee 「畢業」小夥伴訪談錄(一)
- Shopee 「畢業」小夥伴訪談錄(三)
- Shopee 「畢業」小夥伴訪談錄(二)
- 我在 Shopee 畢業了
- Vue 編譯三部曲:模型樹優化
- Vue 編譯三部曲:如何將 template 編譯成 AST ?
- 為什麼 JSX 語法這麼香?
- Vue3 中有場景是 reactive 能做而 ref 做不嗎?
- 溫故而知新,Vue2/3 Computed 的原始碼解析
- 如何設計我們系統的樣式檔案?
- 面試裝X:深入理解 DNS 解析
- 圖片優化不完全指北
- 如何在專案中優雅的使用對話方塊?
- 前端基石:建構函式和普通函式
- 前端基石:高階函式之柯里化、組合函式、惰性思想
- 前端基石:閉包
- 前端基石:預處理機制,變數提升
- 面試裝X:我知道的前端跨頁面通訊