全面了解 Vue.js 函數(shù)式組件

如果你是一位前端開發(fā)者,又在某些機會下閱讀過一些 Java 代碼,可能會在后者中看到一種類似 ES6 語法中箭頭函數(shù)的寫法
(String?a,?String?b)?->?a.toLowerCase()?+?b.toLowerCase();
這種從 Java 8 后出現(xiàn)的 lambda 表達式,在 C++ / Python 中都有出現(xiàn),它比傳統(tǒng)的 OOP 風(fēng)格代碼更緊湊;雖然 Java 中的這種表達式本質(zhì)上還是一個生成類實例的函數(shù)式接口(functional interface)語法糖,但無論其簡潔的寫法,還是處理不可變值并映射成另一個值的行為,都是典型的函數(shù)式編程(FP - functional programming)特征。
1992 年的圖靈獎得主 Butler Lampson 有一個著名的論斷:
All problems in computer science can be solved by another level of indirection
計算機科學(xué)中的任何問題都可以通過增加一個間接層次來解決
這句話中的“間接層次”常被翻譯成“抽象層”,盡管有人曾爭論過其嚴謹性,但不管怎么翻譯都還說得通。無論如何,OOP 語言擁抱 FP,都是編程領(lǐng)域日益融合并重視函數(shù)式編程的直接體現(xiàn),也印證了通過引入另一個間接層次來解決實際問題的這句“軟件工程基本定理”。
還有另一句同樣未必那么嚴謹?shù)牧餍姓f辭是:
OOP 是對數(shù)據(jù)的抽象,而 FP 用來抽象行為
不同于面向?qū)ο缶幊讨?,通過抽象出各種對象并注重其間的解耦問題等;函數(shù)式編程聚焦于最小的單項操作,將復(fù)雜任務(wù)變成一次次 f(x) = y 式的函數(shù)運算疊加。函數(shù)是 FP 中的一等公民(First-class object),可以被當成函數(shù)參數(shù)或被函數(shù)返回。
同時在 FP 中,函數(shù)應(yīng)該不依賴或影響外部狀態(tài),這意味著對于給定的輸入,將產(chǎn)生相同的輸出 -- 這也就是 FP 中常常使用“不可變(immutable)”、“純函數(shù)(pure)”等詞語的緣由;如果再把前面提過的 “l(fā)ambda 演算”,以及 “curring 柯里化” 等掛在嘴邊,你聽上去就是個 FP 愛好者了。
以上這些概念及其相關(guān)的理論,集中誕生在 20 世紀前半葉,眾多科學(xué)家對數(shù)理邏輯的研究收獲了豐碩的成果;甚至現(xiàn)在熱門的 ML、AI 等都受益于這些成果。比如當時大師級的美國波蘭裔數(shù)學(xué)家 Haskell Curry,他的名字就毫不浪費地留在了 Haskell 語言和柯里化這些典型的函數(shù)式實踐中。
React 函數(shù)式組件
如果使用過 jQuery / RxJS 時的“鏈式語法”,其實就可以算做 FP 中 monad 的實踐;而近年來大多數(shù)前端開發(fā)者真正接觸到 FP,一是從 ES6 中引入的 map / reduce 等幾個函數(shù)式風(fēng)格的 Array 實例方法,另一個就是從 React 中的函數(shù)式組件(FC - functional component)開始的。
React 中的函數(shù)式組件也常被叫做無狀態(tài)組件(Stateless Component),更直觀的叫法則是渲染函數(shù)(render function),因為寫出來真的就是個用來渲染的函數(shù)而已:
const?Welcome?=?(props)?=>?{?
??return?<h1>Hello,?{props.name}h1>;?
}
結(jié)合 TypeScript 的話,還可以使用 type 和 FC 來對這個返回了 jsx 的函數(shù)約束入?yún)ⅲ?/p>
type?GreetingProps?=?{
?name:?string;
}
const?Greeting:React.FC?=?({?name?})?=>?{
?return?<h1>Hello?{name}h1>
};
也可以用 interface 和范型,更靈活地定義 props 類型:
interface?IGreeting'm'?|?'f'>?{
?name:?string;
?gender:?T
}
export?const?Greeting?=?({?name,?gender?}:?IGreeting<0?|?1>):?JSX.Element?=>?{
?return?<h1>Hello?{?gender?===?0???'Ms.'?:?'Mr.'?}?{name}h1>
};
Vue(2.x) 中的函數(shù)式組件
在 Vue 官網(wǎng)文檔的【函數(shù)式組件】章節(jié)中,這樣描述到:
...我們可以將組件標記為 functional,這意味它無狀態(tài)?(沒有響應(yīng)式數(shù)據(jù)),也沒有實例?(沒有 this 上下文)。一個函數(shù)式組件就像這樣:
?
Vue.component('my-component',?{
??functional:?true,
??//?Props?是可選的
??props:?{
????//?...
??},
??//?為了彌補缺少的實例
??//?提供第二個參數(shù)作為上下文
??render:?function?(createElement,?context)?{
????//?...
??}
})
...
?
在 2.5.0?及以上版本中,如果你使用了[單文件組件],那么基于模板的函數(shù)式組件可以這樣聲明:
<template?functional>
template>
寫過 React 并第一次閱讀到這個文檔的開發(fā)者,可能會下意識地發(fā)出 “啊這...” 的感嘆,寫上個 functional 就叫函數(shù)式了???
實際上在 Vue 3.x 中,你還真的能和 React 一樣寫出那種純渲染函數(shù)的“函數(shù)式組件”,這個我們后面再說。
在目前更通用的 Vue 2.x 中,正如文檔中所說,一個函數(shù)式組件(FC - functional component)就意味著一個沒有實例(沒有 this 上下文、沒有生命周期方法、不監(jiān)聽任何屬性、不管理任何狀態(tài))的組件。從外部看,它大抵也是可以被視作一個只接受一些 prop 并按預(yù)期返回某種渲染結(jié)果的 fc(props) => VNode 函數(shù)的。
并且,真正的 FP 函數(shù)基于不可變狀態(tài)(immutable state),而 Vue 中的“函數(shù)式”組件也沒有這么理想化 -- 后者基于可變數(shù)據(jù),相比普通組件只是沒有實例概念而已。但其優(yōu)點仍然很明顯:
因為函數(shù)式組件忽略了生命周期和監(jiān)聽等實現(xiàn)邏輯,所以渲染開銷很低、執(zhí)行速度快 相比于普通組件中的 v-if等指令,使用 h 函數(shù)或結(jié)合 jsx 邏輯更清晰更容易地實現(xiàn)高階組件(HOC - higher-order component)模式,即一個封裝了某些邏輯并條件性地渲染參數(shù)子組件的容器組件 可以通過數(shù)組返回多個根節(jié)點
?? 舉個栗子:優(yōu)化 el-table 中的自定義列
先來直觀感受一個適用 FC 的典型場景:

這是 ElementUI 官網(wǎng)中對自定義表格列給出的例子,其對應(yīng)的 template 部分代碼為:
<template>
??<el-table
????:data="tableData"
????style="width:?100%">
????<el-table-column
??????label="日期"
??????width="180">
??????<template?slot-scope="scope">
????????<i?class="el-icon-time">i>
????????<span?style="margin-left:?10px">{{?scope.row.date?}}span>
??????template>
????el-table-column>
????<el-table-column
??????label="姓名"
??????width="180">
??????<template?slot-scope="scope">
????????<el-popover?trigger="hover"?placement="top">
??????????<p>姓名:?{{?scope.row.name?}}p>
??????????<p>住址:?{{?scope.row.address?}}p>
??????????<div?slot="reference"?class="name-wrapper">
????????????<el-tag?size="medium">{{?scope.row.name?}}el-tag>
??????????div>
????????el-popover>
??????template>
????el-table-column>
????<el-table-column?label="操作">
??????<template?slot-scope="scope">
????????<el-button
??????????size="mini"
??????????@click="handleEdit(scope.$index,?scope.row)">編輯el-button>
????????<el-button
??????????size="mini"
??????????type="danger"
??????????@click="handleDelete(scope.$index,?scope.row)">刪除el-button>
??????template>
????el-table-column>
??el-table>
template>
在實際業(yè)務(wù)需求中,像文檔示例中這種小表格當然存在,但并不會成為我們關(guān)注的重點;ElementUI 自定義表格列被廣泛地用于各種字段繁多、交互龐雜的大型報表的渲染邏輯中,通常是 20 個以上的列起步,并且每個列中圖片列表、視頻預(yù)覽彈窗、需要組合和格式化的段落、根據(jù)權(quán)限或狀態(tài)而數(shù)量不定的操作按鈕等等,不一而足;相關(guān)的 template 部分也經(jīng)常是幾百行甚至更多,除了冗長,不同列直接相似的邏輯難以復(fù)用也是個問題。
正如電視劇《老友記》中臺詞所言:
歡迎來到現(xiàn)實世界!它糟糕得要命~ 但你會愛上它!
vue 單文件組件中并未提供 include 等拆分 template 的方案 -- 畢竟語法糖可夠多了,沒有最好。
有潔癖的開發(fā)者會嘗試將復(fù)雜的列模版部分封裝成獨立的組件,來解決這個痛點;這樣已經(jīng)很好了,但相比于本來的寫法又產(chǎn)生了性能隱患。
回想起你在面試時,回答關(guān)于如何優(yōu)化多層節(jié)點渲染問題時那種氣吞萬里的自信??,我們顯然在應(yīng)該在這次的實踐中更進一步,既能拆分關(guān)注點,又要避免性能問題,函數(shù)式組件就是一種這個場景下合適的方案。
首先嘗試的是把原本 template 中日期列的部分“平移”到一個函數(shù)式組件 DateCol.vue 中:
<template?functional>
??<div>
????<i?class="el-icon-time">i>
????<span?style="margin-left:?10px;?color:?blue;">{{?props.row.date?}}span>
??div>
template>

在容器頁面中 import 后聲明在 components 中并使用:

基本是原汁原味;唯一的問題是受限于單個根元素的限制,多套了一層 div,這一點上也可以用 vue-fragment 等加以解決。
接下來我們將姓名列重構(gòu)為 NameCol.js:
export?default?{
??functional:?true,
??render(h,?{props})?{
????const?{row}?=?props;
????return?h('el-popover',?{
????????props:?{trigger:?"hover",?placement:?"top"},
????????scopedSlots:?{
??????????reference:?()?=>?h('div',?{class:?"name-wrapper"},?[
????????????h('el-tag',?{props:?{size:?'medium'}},?[row.name?+?'~'])
??????????])
????????}
??????},?[
??????????h('p',?null,?[`姓名:?${?row.name?}`]),
??????????h('p',?null,?[`住址:?${?row.address?}`])
??????])
??}
}


效果沒得說,還用數(shù)組規(guī)避了單個根元素的限制;更重要的是,抽象出來的這個小組件是真正的 js 模塊,你可以不用
