為什么 JSX 語法這么香?
前言
時下雖然接入 JSX 語法的框架(React、Vue)越來越多,但與之緣分最深的毫無疑問仍然是 React。2013 年,當(dāng) React 帶著 JSX 橫空出世時,社區(qū)曾對 JSX 有過不少的爭議,但如今,越來越多的人面對 JSX 都要說上一句“真香”!典型的“真香”系列。
JSX 是什么?
按照 React 官方的解釋[2],JSX 是一個 JavaScript 的語法擴(kuò)展,類似于模板語法,或者說是一個類似于 XML 的 ECMAScript 語法擴(kuò)展,并且具備 JavaScript 的全部功能。
這段解釋可抽離兩個關(guān)鍵點(diǎn):
「JavaScript 語法擴(kuò)展」 「具備JavaScript 的全部功能」
JSX 的定位是 JavaScript 的「語法擴(kuò)展」,而不是“某個版本”,這就決定了瀏覽器并不會像天然支持 JavaScript 一樣支持 JSX 。這就引出了一個問題 “JSX 是如何在 JavaScript 中生效的?”
JSX 語法是如何在 JavaScript 中生效的?
React
在 React 框架中,JSX 的語法是如何在 JavaScript 中生效的呢?React 官網(wǎng)給出的解釋是,JSX 會被編譯為 React.createElement(), React.createElement() 將返回一個叫作“React Element”的 JS 對象。
對于 JSX 的編譯是由 Babel 來完成的。
Babel 是一個工具鏈,主要用于將采用 ECMAScript 2015+ 語法編寫的代碼轉(zhuǎn)換為向后兼容的 JavaScript 語法,以便能夠運(yùn)行在當(dāng)前和舊版本的瀏覽器或其他環(huán)境中。
當(dāng)然 Babel 也具備將 JSX 轉(zhuǎn)換為 JS 的能力,看一個例子:左邊是我們 React 開發(fā)中寫到的語法,并且包含了一段 JSX 代碼。經(jīng)過 Babel 轉(zhuǎn)換之后,就全部變成了 JS 代碼。

其實(shí)如果仔細(xì)看,發(fā)現(xiàn) JSX 更像是一種語法糖,通過類似模板語法的描述方式,描述函數(shù)對象。其實(shí)在 React 中并不會強(qiáng)制使用 JSX 語法,我們也可以使用 React.createElement 函數(shù),例如使用 React.createElement 函數(shù)寫這樣一段代碼。
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 之后,這段代碼會這樣寫:
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')
);
通過對比發(fā)現(xiàn),在實(shí)際功能效果一致的前提下,JSX 代碼層次分明、嵌套關(guān)系清晰;而 React.createElement 代碼則給人一種非常混亂的“雜糅感”,這樣的代碼不僅讀起來不友好,寫起來也費(fèi)勁。
JSX 語法寫出來的代碼更為的簡潔,而且代碼結(jié)構(gòu)層次更加的清晰。
JSX 語法糖允許我們開發(fā)人員像寫 HTML 一樣來寫我們的 JS 代碼。在降低學(xué)習(xí)成本的同時還提升了我們的研發(fā)效率和研發(fā)體驗(yàn)。
Vue
當(dāng)然在 Vue 框架中也不例外的可以使用 JSX 語法,雖然 Vue 默認(rèn)推薦的還是模板。
為什么默認(rèn)推薦的模板語法,引用一段 Vue 官網(wǎng)的原話如下:
任何合乎規(guī)范的 HTML 都是合法的 Vue 模板,這也帶來了一些特有的優(yōu)勢:
對于很多習(xí)慣了 HTML 的開發(fā)者來說,模板比起 JSX 讀寫起來更自然。這里當(dāng)然有主觀偏好的成分,但如果這種區(qū)別會導(dǎo)致開發(fā)效率的提升,那么它就有客觀的價值存在。 基于 HTML 的模板使得將已有的應(yīng)用逐步遷移到 Vue 更為容易。 這也使得設(shè)計(jì)師和新人開發(fā)者更容易理解和參與到項(xiàng)目中。 你甚至可以使用其他模板預(yù)處理器,比如 Pug 來書寫 Vue 的模板。
有些開發(fā)者認(rèn)為模板意味著需要學(xué)習(xí)額外的 DSL (Domain-Specific Language 領(lǐng)域特定語言) 才能進(jìn)行開發(fā)——我們認(rèn)為這種區(qū)別是比較膚淺的。首先,JSX 并不是沒有學(xué)習(xí)成本的——它是基于 JS 之上的一套額外語法。同時,正如同熟悉 JS 的人學(xué)習(xí) JSX 會很容易一樣,熟悉 HTML 的人學(xué)習(xí) Vue 的模板語法也是很容易的。最后,DSL 的存在使得我們可以讓開發(fā)者用更少的代碼做更多的事,比如 v-on 的各種修飾符,在 JSX 中實(shí)現(xiàn)對應(yīng)的功能會需要多得多的代碼。
更抽象一點(diǎn)來看,我們可以把組件區(qū)分為兩類:一類是偏視圖表現(xiàn)的 (presentational),一類則是偏邏輯的 (logical)。我們推薦在前者中使用模板,在后者中使用 JSX 或渲染函數(shù)。這兩類組件的比例會根據(jù)應(yīng)用類型的不同有所變化,但整體來說我們發(fā)現(xiàn)表現(xiàn)類的組件遠(yuǎn)遠(yuǎn)多于邏輯類組件。
例如有這樣一段模板語法。
<anchored-heading :level="1">
<span>Hello</span> world!
</anchored-heading>
使用 JSX 語法會寫成這樣。
render: function (h) {
return (
<AnchoredHeading level={1}>
<span>Hello</span> world!
</AnchoredHeading>
)
}
轉(zhuǎn)換為 createElement 轉(zhuǎn)換的 JS 就變成了這樣。
createElement(
'anchored-heading', {
props: {
level: 1
}
}, [
createElement('span', 'Hello'),
' world!'
]
);
但是不管是模板語法還是 JSX 語法,都不會得到瀏覽器純天然的支持,這些語法最后都會被編譯成相應(yīng)的 h 函數(shù)(createElement函數(shù),不泛指所有版本,在不同版本有差異)最后變成 JS 對象,這里的編譯也是和 React 一樣使用的 Babel 插件[3]來完成的。
不管是 React 推崇的 JSX 語法,還是 Vue 默認(rèn)的模板語法,目的都是為了讓我們寫出來的代碼更為的簡潔,而且代碼接口層次更加的清晰。在降低學(xué)習(xí)成本的同時還提升了我們的研發(fā)效率和研發(fā)體驗(yàn)。
讀到這里,相信你已經(jīng)充分理解了“JSX 是 JavaScript 的一種語法擴(kuò)展,它和模板語言很接近,并且具備 JavaScript 的全部功能。”這一定義背后的深意。
不管是 React 還是 Vue 我們都提到了一個函數(shù) createElement,這個函數(shù)就是將我們的 JSX 映射為 DOM的。
JSX 是如何映射為 DOM 的:起底 createElement 源碼
對于 creatElement 源碼的分析,我們也分 React 和 Vue 來為大家解讀。
源碼分析的具體版本沒有必要去過于詳細(xì)的討論,因?yàn)椴还苁?React 還是 Vue 對于在實(shí)現(xiàn) createElement 上在不同版本差別不大。
React
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) {
// 進(jìn)來之后做的第一件事,是依次對 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 (
// 篩選出可以提進(jìn) props 對象里的屬性
hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)
) {
props[propName] = config[propName];
}
}
}
// childrenLength 指的是當(dāng)前元素的子元素的個數(shù),減去的 2 是 type 和 config 兩個參數(shù)占用的長度
const childrenLength = arguments.length - 2;
// 如果拋去type和config,就只剩下一個參數(shù),一般意味著文本節(jié)點(diǎn)出現(xiàn)了
if (childrenLength === 1) {
// 直接把這個參數(shù)的值賦給props.children
props.children = children;
// 處理嵌套多個子元素的情況
} else if (childrenLength > 1) {
// 聲明一個子元素數(shù)組
const childArray = Array(childrenLength);
// 把子元素推進(jìn)數(shù)組里
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
// 最后把這個數(shù)組賦值給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];
}
}
}
// 最后返回一個調(diào)用ReactElement執(zhí)行方法,并傳入剛才處理過的參數(shù)
return ReactElement(
type,
key,
ref,
self,
source,
ReactCurrentOwner.current,
props,
);
}
createElement 函數(shù)有 3 個入?yún)ⅲ@ 3 個入?yún)宋覀冊趧?chuàng)建一個 React 元素的全部信息。
type:用于標(biāo)識節(jié)點(diǎn)的類型。可以是原生態(tài)的 div 、span 這樣的 HTML 標(biāo)簽,也可以是 React 組件,還可以是 React fragment(空元素)。 config:一個對象,組件所有的屬性(不包含默認(rèn)的一些屬性)都會以鍵值對的形式存儲在 config 對象中。 children:泛指第二個參數(shù)后的所有參數(shù),它記錄的是組件標(biāo)簽之間嵌套的內(nèi)容,也就是所謂的“子節(jié)點(diǎn)”“子元素”。

從源碼角度來看,createElement 函數(shù)就是將開發(fā)時研發(fā)人員寫的數(shù)據(jù)、屬性、參數(shù)做一層格式化,轉(zhuǎn)化為 React 好理解的參數(shù),然后交付給 ReactElement 來實(shí)現(xiàn)元素創(chuàng)建。
接下來我們來看看 ReactElement 函數(shù)
const ReactElement = function(type, key, ref, self, source, owner, props) {
const element = {
// 標(biāo)記這是個 React Element
$$typeof: REACT_ELEMENT_TYPE,
type: type,
key: key,
ref: ref,
props: props,
_owner: owner,
};
return element;
};
源碼異常的簡單,也就是對 createElement 函數(shù)轉(zhuǎn)換的參數(shù),在進(jìn)行一次處理,包裝進(jìn) element 對象中返給開發(fā)者。如果你試過將這個返回 ReactElement 進(jìn)行輸出,你會發(fā)現(xiàn)有沒有很熟悉的感覺,沒錯,這就是我們老生常談的「虛擬 DOM」,JavaScript 對象對 DOM 的描述。
最后通過 ReactDOM.render 方法將虛擬DOM 渲染到指定的容器里面。

Vue
Vue 2
我們在來看看 Vue 是如何映射 DOM 的。
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 函數(shù)就是對 _createElement 函數(shù)的一個封裝,它允許傳入的參數(shù)更加靈活,在處理這些參數(shù)后,調(diào)用真正創(chuàng)建 VNode 的函數(shù) _createElement:
export function _createElement (
context: Component,
tag?: string | Class<Component> | Function | Object,
data?: VNodeData,
children?: any,
normalizationType?: number
): VNode | Array<VNode> {
...
return vnode;
}
_createElement 方法有 5 個參數(shù):
context 表示 VNode 的上下文環(huán)境。 tag 表示標(biāo)簽,它可以是一個字符串,也可以是一個 Component。 data 表示 VNode 的數(shù)據(jù)。 children 表示當(dāng)前 VNode 的子節(jié)點(diǎn),它是任意類型的,它接下來需要被規(guī)范為標(biāo)準(zhǔn)的 VNode 數(shù)組。 normalizationType 表示子節(jié)點(diǎn)規(guī)范的類型,類型不同規(guī)范的方法也就不一樣,它主要是參考 render 函數(shù)是編譯生成的還是用戶手寫的。
_createElement 實(shí)現(xiàn)內(nèi)容略多,這里就不詳細(xì)分析了,反正最后都會創(chuàng)建一個 VNode ,每個 VNode 有 children,children 每個元素也是一個 VNode,這樣就形成了一個 VNode Tree,它很好的描述了我們的 DOM Tree。

當(dāng) VNode 創(chuàng)建好之后,就下來就是把 VNode 渲染成一個真實(shí)的 DOM 并渲染出來。這個過程是通過 vm._update 完成的。Vue 的 _update 是實(shí)例的一個私有方法,它被調(diào)用的時機(jī)有 2 個,一個是首次渲染,一個是數(shù)據(jù)更新的時候,我們這里只看首次渲染;當(dāng)調(diào)用 _update 時,核心就是調(diào)用 vm.patch 方法。
patch:這個方法實(shí)際上在不同的平臺,比如 web 和 weex 上的定義是不一樣的
引入一段代碼來看看具體實(shí)現(xiàn)。
var app = new Vue({
el: '#app',
render: function (createElement) {
return createElement('div', {
attrs: {
id: 'app'
},
}, this.message)
},
data: {
message: 'Hello Vue!'
}
});
在 vm._update 的方法里是這么調(diào)用 patch 方法的:
if (!prevVnode) {
// 首次渲染
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
} else {
// 更新
vm.$el = vm.__patch__(prevVnode, vnode);
}
首次渲染:
$el 對應(yīng)的就是 id 為 app 的 DOM 元素。 vnode 對應(yīng)的是 render 函數(shù)通過 createElement 函數(shù)創(chuàng)建的 虛擬 DOM。 hydrating 在非服務(wù)端渲染情況下為 false。
確認(rèn)首次渲染的參數(shù)之后,我們再來看看 patch 的執(zhí)行過程。一段又臭又長的源碼。
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 元素 ) 實(shí)際上是一個 DOM container,接下來又通過 emptyNodeAt 方法把 oldVnode 轉(zhuǎn)換成 VNode 對象,然后再調(diào)用 createElm 方法,通過虛擬節(jié)點(diǎn)創(chuàng)建真實(shí)的 DOM 并插入到它的父節(jié)點(diǎn)中。

通過起底 React 和 Vue 的 createElement 源碼,分析了 JSX 是如何映射為真實(shí) DOM 的,實(shí)現(xiàn)思路的整體方向都是一樣的。所以說優(yōu)秀的框架大家都在相互借鑒,相互學(xué)習(xí)。
為什么 React 一開始就選擇 JSX?
在 2013 年,React 帶著 JSX 語法出現(xiàn),剛出現(xiàn)時飽受爭議,為什么 React 會選擇 JSX?而不是其他的語法。比如:
模板
模板語法比較典型的是 AngularJS,如果你用過 AngularJS,你會發(fā)現(xiàn)對于模板會引入很多的概念,比如新的模板語法、新的模板指令。
<div ng-controller="Ctrl1">
Hello <input ng-model='name'> <hr/>
<span ng-bind="name"></span> <br/>
<span ng:bind="name"></span> <br/>
<span ng_bind="name"></span> <br/>
<span data-ng-bind="name"></span> <br/>
<span x-ng-bind="name"></span> <br/>
</div>
angular.module('test', [])
.controller('Ctrl1', function Ctrl1($scope) {
$scope.name = '1';
});
React 的設(shè)計(jì)初衷是**「關(guān)注點(diǎn)分離」,React 本身的關(guān)注基本單位是組件,在組件內(nèi)部高內(nèi)聚,組件之間低耦合。而模板語法做不到。并且 JSX 并不會引入太多的新的概念。** 也可以看出 React 代碼更簡潔,更具有可讀性,更貼近 HTML。
const App = (props) => {
return (
<div>
xxx
</div>
)
}
模板字符串
JSX 的語法淺看有一點(diǎn)像模板字符串,如果在早幾年,使用過 PHP + JQuery 技術(shù)棧的同學(xué)可能寫過類似這樣語法的代碼。
var box = jsx`
<${Box}>
${
true ?
jsx`<${Box.Comment}>
Text Content
</${Box.Comment}>` :
jsx`
<${Box.Comment}>
Text Content
</${Box.Comment}>
`
}
</${Box}>
`;
不知你怎么看,反正我當(dāng)時在寫這樣代碼的時候是很痛苦的,并且代碼結(jié)果變得更加復(fù)雜,不利于后期的維護(hù)。
JXON
<catalog>
<product description="Cardigan Sweater">
1111
</product>
<script type="text/javascript"><![CDATA[function matchwo(a,b) {
if (a < b && a < 0) { return 1; }
else { return 0; }
}]]>
</script>
</catalog>
但最終放棄 JXON 這一方案的原因是,大括號不能為元素在樹中開始和結(jié)束的位置,提供很好的語法提示。
template
<template>
<div>1</div>
<template>
<script>
....
<script>
那為什么不能和 Vue 一樣使用 模板語法了?JSX 本質(zhì)就是 JavaScript,想實(shí)現(xiàn)條件渲染可以用 if else,也可以用三元表達(dá)式,還可以用任意合法的 JavaScript 語法。也就是說,JSX 可以支持更動態(tài)的需求。而 template 則因?yàn)檎Z法限制原因,不能夠像 JSX 那樣可以支持更動態(tài)的需求。這是 JSX 相比于 template 的一個優(yōu)勢。 JSX 相比于 template 還有一個優(yōu)勢,是可以在一個文件內(nèi)返回多個組件。
但是就 Vue 來說,默認(rèn)選擇 template 語法也是有原因的,template 由于語法固定,可以在編譯層面做的優(yōu)化較多,比如靜態(tài)標(biāo)記就真正做到了按需更新;而 JSX 由于動態(tài)性太強(qiáng),只能在有限的場景下做優(yōu)化,雖然性能不如 template 好,但在某些動態(tài)性要求較高的場景下,JSX 成了標(biāo)配,這也是諸多組件庫會使用 JSX 的主要原因。
總結(jié)
通過對比多種方案,發(fā)現(xiàn) JSX 本身具備他獨(dú)享的優(yōu)勢,JSX 語法寫出來的代碼更為的簡潔,而且代碼結(jié)構(gòu)層次更加的清晰。JSX 語法糖允許我們開發(fā)人員像寫 HTML 一樣來寫我們的 JS 代碼。在降低學(xué)習(xí)成本的同時還提升了我們的研發(fā)效率和研發(fā)體驗(yàn)。
并且JSX 本身沒有太多的語法,也不期待引入更多的標(biāo)準(zhǔn)。實(shí)際上,在 16 年的時候,JSX 公布過 2.0 的建設(shè)計(jì)劃與小部分新特性,但很快被 Facebook 放棄掉了。整個計(jì)劃在公布不到兩個月的時間里便停掉了。其中一個原因是 JSX 的設(shè)計(jì)初衷,即并不希望引入太多的標(biāo)準(zhǔn),也不期望 JSX 加入瀏覽器或者 ECMAScript 標(biāo)準(zhǔn)。
