0. Something To Say
該系列文章計劃中一共有三篇,在這三篇文章裡我將手把手教大家使用 Babel 為 React 實現雙向資料繫結。在這系列文章你將:
- 瞭解一些非常基本的編譯原理中的概念
- 瞭解 JS 編譯的過程與原理
- 學會如何編寫 babel-plugin
- 學會如何修改 JS AST 來實現自定義語法
該系列文章實現的 babel-plugin-jsx-two-way-binding 在我的 GitHub 倉庫,歡迎參考或提出建議。
你也可以使用 npm install --save-dev babel-plugin-jsx-two-way-binding
來安裝並直接使用該 babel-plugin。
另:本人 18 屆前端萌新正在求職,如果有大佬覺得我還不錯,請私信我或給我發郵件: i@do.codes!(~ ̄▽ ̄)~附:我的簡歷。
1. Why
在 Angular、Vue 等現代前端框架中,雙向資料繫結是一個很有用的特性,為處理表單帶來了很大的便利。
React 官方一直提倡單向資料流的思想,雖然我個人十分喜歡 React 的設計哲學,但在實際需求中,有時會遇到 View 層與 Model 層存在大量的資料需要同步的情況,這時為每一個表單都新增一個 Handler 反而會讓事情變得更加繁瑣。
2. How
不難發現,這種情況在 React 中總是有相同的的處理方法:通過 “value” 屬性實現 Model => View 的資料流,通過繫結 “ onChange” Handler 實現 View => Model 的資料流。
由於 JSX 不能直接在瀏覽器執行,需要使用 Babel 編譯成普通的 JS 檔案, 因此這讓我們有機會在編譯時對程式碼進行處理實現無需 Runtime 的雙向資料繫結。
如: 在 JSX 中,在 “Input” 標籤中使用 “model” 屬性來指定要繫結的資料:
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
name: 'Joe'
}
}
render() { return (
<div>
<h1>I'm {this.state.name}</h1>
<input type="text" model={this.state.name}/>
</div>
)}
}複製程式碼
繫結 “model” 屬性的標籤在編譯時將會同時被繫結的 “value” 屬性和 “onChange” Handler:
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
name: 'Joe'
}
}
render() { return (
<div>
<h1>I'm {this.state.name}</h1>
<input
type="text"
value={this.state.name}
onChange={e => this.setState({ name: e.target.value })}
/>
</div>
)}
}複製程式碼
3. About Babel
下面需要了解一些知識:
Babel 編譯 JS 檔案的步驟分為解析(parse),轉換(transform),生成(generate)三個步驟。
解析步驟接收程式碼並輸出 AST(Abstract syntax tree: 抽象語法樹, 參考: en.wikipedia.org/wiki/Abstra… 這個步驟分為兩個階段:詞法分析(Lexical Analysis)和語法分析(Syntactic Analysis)。
轉換步驟接收 AST 並對其進行遍歷,在此過程中對節點進行新增、更新及移除等操作。
程式碼生成步驟深度優先遍歷最終的 AST 轉換成字串形式的程式碼,同時還會建立原始碼對映(source maps)。
要達到我們的目標,我們需要在轉換步驟操作 AST 並對其進行更改。 AST 在 Babel 中以 JS 物件的形式存在,因此我們需要遍歷每一個 AST 節點。
在 Babel 及其他很多編譯器中,都使用訪問者模式來遍歷 AST 節點(參考: Visitor pattern - Wikipedia)。當我們談及遍歷到一個 AST 節點時,實際上我們是在訪問它,這時 Babel 將會呼叫該型別節點的 Handler。如,當訪問到一個函式宣告時(FunctionDeclaration),將會呼叫 FunctionDeclaration() 方法並將當前訪問的節點作為引數傳入該函式。我們需要做的工作就是編寫對應訪問者的 Handler 來處理新增了雙向資料繫結的標籤的 AST 併為其新增 “value” 屬性 和 “onChange” handler。
一個重要的工具:
AST Explorer(AST explorer):可以把我們的程式碼轉換為 Babel AST 樹,我們需要參考它來對我們的 AST 樹進行修改。
一些參考資料:
BabelHandBook (GitHub - thejameskyle/babel-handbook: A guided handbook on how to use Babel and how to create plugins for Babel.):教你如何使用 Babel 以及如何編寫 Babel 外掛和預設。
BabelTypes 文件(babel/packages/babel-types at master · babel/babel · GitHub):我們需要查閱該文件來構建新的的 AST 節點。
4. Let‘s Do It!
首先,使用 npm init
建立一個空的專案,然後在專案目錄下建立 “index.js”:
module.exports = function ({ types: t }) {
return {
visitor: {
JSXElement: function(node) {
// TODO
}
}
}
};複製程式碼
在 “index.s” 中我們匯出一個方法作為該 babel-plugin 的主體,該方法接受一個 babel 物件作為引數,返回一個包含各個 Visitor 方法的物件。傳入的 babel 物件包含一個 types 屬性,它用來構造新的 AST 節點,如,可以使用 t.jSXAttribute(name, value)
來構造一個新的 JSX 屬性節點; 每個 Visitor 方法接受一個 Path 作為引數。AST 通常會有許多節點,babel 使用一個可操作和訪問的巨大可變物件表示節點之間的關聯關係。Path 是表示兩個節點之間連線的物件。
因為我們要修改 JSX 標籤的屬性並對其新增 “value” 和 “onChange” 屬性,因此我們需要在 JSXElement Visitor Handler 中遍歷 JSXAttribute。Visitor Handler 中傳入的的 Path 引數中有個 traverse 方法可以用來遍歷所有的節點。現在,我們來新增一個遍歷 JSX 屬性的方法:
module.exports = function ({ types: t }) {
function JSXAttributeVisitor(node) {
// TODO
}
function JSXElementVisitor(path) {
path.traverse({
JSXAttribute: JSXAttributeVisitor
});
}
return {
visitor: {
JSXElement: JSXElementVisitor
}
}
}複製程式碼
然後我們來具體實現 JSXAttributeVisitor 方法。首先,我們需要拿到雙向資料繫結的值,並儲存到一個變數(我們預設使用 “model” 屬性來進行雙向資料繫結),然後把 “model” 屬性名改為 “value”:
function JSXAttributeVisitor(node) {
if (node.node.name.name === 'model') {
const model = node.node.value.expression;
// 將 model 屬性名改為 value
node.node.name.name = 'value';
}
}複製程式碼
這時我們拿到的 model 屬性是一個 expression 物件,我們需要將其轉化成類似 “this.state.name” 這樣的字串方便我們在後面使用,在這裡我們實現一個方法將 expression 物件轉換成字串:
// 把 expression AST 轉換為類似 “this.state.name” 這樣的字串
function objExpression2Str(expression) {
let objStr;
switch (expression.object.type) {
case 'MemberExpression':
objStr = objExpression2Str(expression.object);
break;
case 'Identifier':
objStr = expression.object.name;
break;
case 'ThisExpression':
objStr = 'this';
break;
}
return objStr + '.' + expression.property.name;
}複製程式碼
因為我們需要在自動繫結的 handler 裡面使用 “this.setState” 方法,因此我們暫時只考慮對 State 物件的資料繫結進行處理。讓我們繼續改進 JSXAttributeVisitor 方法:
function JSXAttributeVisitor(node) {
if (node.node.name.name === 'model') {
let modelStr = objExpression2Str(node.node.value.expression).split('.');
// 如果雙向資料繫結的值不是 this.state 的屬性,則不作處理
if (modelStr[0] !== 'this' || modelStr[1] !== 'state') return;
// 將 modelStr 從類似 ‘this.state.name.value’ 變為 ‘name.value’ 的形式
modelStr = modelStr.slice(2, modelStr.length).join('.');
node.node.name.name = 'value';
}
}複製程式碼
然後我們開始構建 onChange Handler 的 AST 節點,因為我們呼叫 “this.setState” 時需要以物件的形式傳入引數,因此我們建立兩個方法,objPropStr2AST 方法以字串傳入 key 和 value,返回一個物件 AST 節點;objValueStr2AST 方法以字串傳入 value,返回物件的屬性的值的 AST 節點:
// 把 key - value 字串轉換為 { key: value } 這樣的物件 AST 節點
function objPropStr2AST(key, value, t) {
return t.objectProperty(
t.identifier(key),
objValueStr2AST(value, t)
);
}複製程式碼
// 把類似 “this.state.name” 這樣的字串轉換為 AST 節點
function objValueStr2AST(objValueStr, t) {
const values = objValueStr.split('.');
if (values.length === 1)
return t.identifier(values[0]);
return t.memberExpression(
objValueStr2AST(values.slice(0, values.length - 1).join('.'), t),
objValueStr2AST(values[values.length - 1], t)
)
}複製程式碼
讓我繼續構建 onChange Handler AST ,接著剛剛的 JSXAttributeVisitor 方法,在後面加上:
// 建立一個函式呼叫節點(建立 AST 節點需要參閱 BabelTypes 文件)
// 需要傳入 callee(呼叫的方法)和 arguments(呼叫時傳入的引數)兩個引數
const setStateCall = t.callExpression(
// 呼叫的方法為 ‘this.setState’
t.memberExpression(
t.thisExpression(),
t.identifier('setState')
),
// 呼叫時傳入的引數為一個物件
// key 為剛剛拿到的 modelStr,value 為 e.target.value
[t.objectExpression(
[objPropStr2AST(modelStr, 'e.target.value', t)]
)]
);複製程式碼
終於,讓我們加上 onChange Handler:
// 使用 insertAfter 方法在當前 JSXAttribute 節點後新增一個新的 JSX 屬性節點
node.insertAfter(t.JSXAttribute(
// 屬性名為 “onChange”
t.jSXIdentifier('onChange'),
// 屬性值為一個 JSX 表示式
t.JSXExpressionContainer(
// 在表示式中使用箭頭函式
t.arrowFunctionExpression(
// 該函式接受引數 ‘e’
[t.identifier('e')],
// 函式體為一個包含剛剛建立的 ‘setState‘ 呼叫的語句塊
t.blockStatement([t.expressionStatement(setStateCall)])
)
)
));複製程式碼
5. Well Done!
恭喜!到這裡我們已經實現了我們需要的基本功能,完整的 ‘index.js’ 程式碼為:
module.exports = function ({ types: t}) {
function JSXAttributeVisitor(node) {
if (node.node.name.name === 'model') {
let modelStr = objExpression2Str(node.node.value.expression).split('.');
// 如果雙向資料繫結的值不是 this.state 的屬性,則不作處理
if (modelStr[0] !== 'this' || modelStr[1] !== 'state') return;
// 將 modelStr 從類似 ‘this.state.name.value’ 變為 ‘name.value’ 的形式
modelStr = modelStr.slice(2, modelStr.length).join('.');
// 將 model 屬性名改為 value
node.node.name.name = 'value';
const setStateCall = t.callExpression(
// 呼叫的方法為 ‘this.setState’
t.memberExpression(
t.thisExpression(),
t.identifier('setState')
),
// 呼叫時傳入的引數為一個物件
// key 為剛剛拿到的 modelStr,value 為 e.target.value
[t.objectExpression(
[objPropStr2AST(modelStr, 'e.target.value', t)]
)]
);
node.insertAfter(t.JSXAttribute(
// 屬性名為 “onChange”
t.jSXIdentifier('onChange'),
// 屬性值為一個 JSX 表示式
t.JSXExpressionContainer(
// 在表示式中使用箭頭函式
t.arrowFunctionExpression(
// 該函式接受引數 ‘e’
[t.identifier('e')],
// 函式體為一個包含剛剛建立的 ‘setState‘ 呼叫的語句塊
t.blockStatement([t.expressionStatement(setStateCall)])
)
)
));
}
}
function JSXElementVisitor(path) {
path.traverse({
JSXAttribute: JSXAttributeVisitor
});
}
return {
visitor: {
JSXElement: JSXElementVisitor
}
}
};
// 把 expression AST 轉換為類似 “this.state.name” 這樣的字串
function objExpression2Str(expression) {
let objStr;
switch (expression.object.type) {
case 'MemberExpression':
objStr = objExpression2Str(expression.object);
break;
case 'Identifier':
objStr = expression.object.name;
break;
case 'ThisExpression':
objStr = 'this';
break;
}
return objStr + '.' + expression.property.name;
}
// 把類似 “this.state.name” 這樣的字串轉換為 AST 節點
function objPropStr2AST(key, value, t) {
return t.objectProperty(
t.identifier(key),
objValueStr2AST(value, t)
);
}
// 把 key - value 字串轉換為 { key: value } 這樣的物件 AST 節點
function objValueStr2AST(objValueStr, t) {
const values = objValueStr.split('.');
if (values.length === 1)
return t.identifier(values[0]);
return t.memberExpression(
objValueStr2AST(values.slice(0, values.length - 1).join('.'), t),
objValueStr2AST(values[values.length - 1], t)
)
}複製程式碼
現在我們已經能夠成功使用 ‘model’ 屬性繫結資料並自動為其新增 ‘value’ 屬性與 ‘onChange’ Handler 來實現雙向資料繫結!
讓我們試試效果:編輯 ‘.babelrc’ 配置檔案:
{
"plugins": [
"path/to/your/index.js(我們建立的 index.js 檔案路徑)",
...
]
}複製程式碼
然後編寫一個 React 元件,你會發現,使用 ‘model’ 屬性即可實現雙向資料繫結,就像在 Angular 或 Vue 裡那樣,簡單而自然!
6. So What‘s Next?
目前我們已經實現了基本的雙向資料繫結,但是還存在一些缺陷:我們手動新增的 onChange Handler 會被覆蓋掉,並且只能對非巢狀的屬性進行繫結!
接下來的兩篇文章裡我們會對這些問題進行解決,歡迎關注我的掘金專欄或 GitHub!
PS:
如果你覺得這篇文章或者 babel-plugin-jsx-two-way-binding 對你有幫助,請不要吝嗇你的點贊或 GitHub Star!如果有錯誤或者不準確的地方,歡迎提出!
本人 18 屆前端萌新正在求職,如果有大佬覺得我還不錯,請私信我或給我發郵件: i@do.codes!(~ ̄▽ ̄)~附:我的簡歷。