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

這是 ElementUI 官網中對自定義表格列給出的例子,其對應的 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è)務需求中,像文檔示例中這種小表格當然存在,但并不會成為我們關注的重點;ElementUI 自定義表格列被廣泛地用于各種字段繁多、交互龐雜的大型報表的渲染邏輯中,通常是 20 個以上的列起步,并且每個列中圖片列表、視頻預覽彈窗、需要組合和格式化的段落、根據權限或狀態(tài)而數量不定的操作按鈕等等,不一而足;相關的 template 部分也經常是幾百行甚至更多,除了冗長,不同列直接相似的邏輯難以復用也是個問題。
正如電視劇《老友記》中臺詞所言:
歡迎來到現(xiàn)實世界!它糟糕得要命~ 但你會愛上它!
vue 單文件組件中并未提供 include 等拆分 template 的方案 -- 畢竟語法糖可夠多了,沒有最好。
有潔癖的開發(fā)者會嘗試將復雜的列模版部分封裝成獨立的組件,來解決這個痛點;這樣已經很好了,但相比于本來的寫法又產生了性能隱患。
回想起你在面試時,回答關于如何優(yōu)化多層節(jié)點渲染問題時那種氣吞萬里的自信😼,我們顯然在應該在這次的實踐中更進一步,既能拆分關注點,又要避免性能問題,函數式組件就是一種這個場景下合適的方案。
首先嘗試的是把原本 template 中日期列的部分“平移”到一個函數式組件 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 等加以解決。
接下來我們將姓名列重構為 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 }`])
])
}
}


效果沒得說,還用數組規(guī)避了單個根元素的限制;更重要的是,抽象出來的這個小組件是真正的 js 模塊,你可以不用 <script> 包裝它而將其放入一個 .js 文件中,更可以自由地做你想做的一切事情了。
h 函數可能帶來些額外的心智負擔,只要再配置上 jsx 支持,那就和原版幾無二致了。
另外這里涉及到的 scopedSlots 以及第三列里將面臨的事件處理等,我們后面慢慢說。
渲染上下文
回顧上面提到的文檔章節(jié),render 函數是這樣的形式:
render: function (createElement, context) {}
實際編碼中一般習慣性地將 createElement 寫為 h,并且即便在 jsx 用法中表面上不用調用 h,還是需要寫上的;在 Vue3 中,則可以用 import { h } from 'vue' 全局引入了。
It comes from the term "hyperscript", which is commonly used in many virtual-dom implementations. "Hyperscript" itself stands for "script that generates HTML structures" because HTML is the acronym for "hyper-text markup language". -- Evan You
官網文檔繼續(xù)寫到:
組件需要的一切都是通過 context 參數傳遞,它是一個包括如下字段的對象:
props:提供所有 prop 的對象
children:VNode 子節(jié)點的數組
slots:一個函數,返回了包含所有插槽的對象
scopedSlots:(2.6.0+) 一個暴露傳入的作用域插槽的對象。也以函數形式暴露普通插槽。
data:傳遞給組件的整個數據對象,作為 createElement 的第二個參數傳入組件
parent:對父組件的引用
listeners:(2.3.0+) 一個包含了所有父組件為當前組件注冊的事件監(jiān)聽器的對象。這是 data.on 的一個別名。
injections:(2.3.0+) 如果使用了 inject 選項,則該對象包含了應當被注入的 property。
這個 context 也就是被定義為 RenderContext 的一個接口類型,在 vue 內部初始化或更新組件時,是這樣形成的:

熟練掌握 RenderContext 接口定義的各種屬性,是我們玩轉函數式組件的基礎。
template
在前面的例子中,我們使用一個帶 functional 屬性的 template 模版,將表格中日期列部分的邏輯抽象為一個獨立模塊。
上面的原理圖中也部分解釋了這一點,Vue 的模板實際上被編譯成了渲染函數,或者說 template 模版和顯式的 render 函數遵循同樣的內部處理邏輯,并且被附加了 $options 等屬性。
也就是說,處理一些復雜的邏輯時,我們依然可以借助 js 的力量,比如在 template 中習慣地調用 methods 等 -- 當然這并非真正的 Vue 組件方法了:

emit
函數式組件中并沒有 this.$emit() 這樣的方法。
但事件回調還是可以正常處理的,需要用到的就是 context.listeners 屬性 -- 正如文檔中提到的,這是 data.on 的一個別名。比如之前的例子中,我們想在容器頁面中監(jiān)聽日期列的圖標被點擊:
<date-col v-bind="scope" @icon-click="onDateClick" />
在 DateCol.vue 中,這樣觸發(fā)事件就可以了:
<i class="el-icon-time"
@click="() => listeners['icon-click'](props.row.date)">
</i>
唯一需要留意的是,雖然以上寫法足以應付大部分情況,但如果外部監(jiān)聽了多個同名事件,listeners 就會變?yōu)橐粋€數組;所以相對完備的一種封裝方法是:
/**
* 用于函數式組件的事件觸發(fā)方法
* @param {object} listeners - context 中的 listeners 對象
* @param {string} eventName - 事件名
* @param {...any} args - 若干參數
* @returns {void} - 無
*/
export const fEmit = (listeners, eventName, ...args) => {
const cbk = listeners[eventName]
if (_.isFunction(cbk)) cbk.apply(null, args)
else if (_.isArray(cbk)) cbk.forEach(f => f.apply(null, args))
}
filter
在 h 函數或 jsx 的返回結構中,傳統(tǒng) Vue 模板中的 <label>{ title | withColon }</label> 過濾器語法不再奏效。
好在原本定義的過濾函數也是普通的函數,所以等效的寫法可以是:
import filters from '@/filters';
const { withColon } = filters;
//...
// render 返回的 jsx 中
<label>{ withColon(title) }</label>
插槽
普通組件 template 部分中使用使用插槽的方法,在函數式組件 render 函數中,包括 jsx 模式下,都無法使用了。
在前面例子中將姓名列重構為 NameCol.js 的時候,已經演示過了相對應的寫法;再看一個 ElementUI 中骨架屏組件的例子,比如普通的 template 用法是這樣的:
<el-skeleton :loading="skeLoading">
real text
<template slot="template">
<p>loading content</p>
</template>
</el-skeleton>
這里面實際就涉及了 default 和 template 兩個插槽,換到函數式組件 render 函數中,對應的寫法為:
export default {
functional: true,
props: ['ok'],
render(h, {props}) {
return h('el-skeleton' ,{
props: {loading: props.ok},
scopedSlots: {
default: () => 'real text',
template: () => h('p', null, ['loading context'])
}
}, null)
}
}
如果遇到 v-bind:user="user" 這樣傳遞了屬性的作用域插槽,那么將 user 作為插槽函數的入參就可以了。
官網文檔中還提及了 slots() 和 children 的對比:
<my-functional-component>
<p v-slot:foo>
first
</p>
<p>second</p>
</my-functional-component>
對于這個組件,children 會給你兩個段落標簽,而 slots().default 只會傳遞第二個匿名段落標簽,slots().foo 會傳遞第一個具名段落標簽。同時擁有 children 和 slots(),因此你可以選擇讓組件感知某個插槽機制,還是簡單地通過傳遞 children,移交給其它組件去處理。
provide / inject
除了文檔中提到的 injections 用法,還要注意 Vue 2 中的 provide / inject 終究是非響應式的。
如果評估后非要使用這種方式,可以試試 vue-reactive-provide
HTML 內容
Vue 中的 jsx 無法支持普通組件 template 中 v-html 的寫法,對應的元素屬性是 domPropsInnerHTML,如:
<strong class={type} domPropsInnerHTML={formatValue(item, type)} />
而在 render 寫法中,又將這個單詞拆分了一下,寫法為:
h('p', {
domProps: {
innerHTML: '<h1>hello</h1>'
}
})
無論如何寫起來都確實費勁了不少,但值得慶幸的是總比 React 中的 dangerouslySetInnerHTML 好記一些。
樣式
如果你采用了純 .js/.ts 的組件,可能唯一的麻煩就是無法再享受 .vue 組件中 scoped 的樣式了;參考 React 的情況,無非是以下幾種方法解決:
- import 外部樣式并采用 BEM 等命名約定
- 在 vue-loader 選項中開啟 CSS Modules 并在組件中應用 styleMod.foo 的形式
- 模塊內動態(tài)構建 style 數組或對象,賦值給屬性
- 采用工具方法動態(tài)構建樣式 class:
const _insertCSS = css => {
let $head = document.head || document.getElementsByTagName('head')[0];
const style = document.createElement('style');
style.setAttribute('type', 'text/css');
if (style.styleSheet) {
style.styleSheet.cssText = css;
} else {
style.appendChild(document.createTextNode(css));
}
$head.appendChild(style);
$head = null;
};
TypeScript
無論是 React 還是 Vue,本身都提供了一些驗證 props 類型的手段。但這些方法一來配置上都稍顯麻煩,二來對于輕巧的函數式組件都有點過“重”了。
TypeScript 作為一種強類型的 JavaScript 超集,可以被用來更精確的定義和檢查 props 的類型、使用更簡便,在 VSCode 或其他支持 Vetur 的開發(fā)工具中的自動提示也更友好。
要將 Vue 函數式組件和 TS 結合起來的話,正如 interface RenderContext<Props> 定義的那樣,對于外部輸入的 props,可以使用一個自定義的 TypeScript 接口聲明其結構,如:
interface IProps {
year: string;
quarters: Array<'Q1' | 'Q2' | 'Q3' | 'Q4'>;
note: {
content: string;
auther: stiring;
}
}
而后指定該接口為 RenderContext 的首個泛型:
import Vue, { CreateElement, RenderContext } from 'vue';
...
export default Vue.extend({
functional: true,
render: (h: CreateElement, context: RenderContext<IProps>) => {
console.log(context.props.year);
//...
}
});
結合 composition-api
與 React Hooks 類似的設計目的很相似的是,Vue Composition API 也在一定程度上為函數式組件帶來了響應式特征、onMounted 等生命周期式的概念和管理副作用的方法。
這里只探討 composition-api 特有的一種寫法 -- 在 setup() 入口函數中返回 render 函數:
比如定義一個 counter.js:
import { h, ref } from "@vue/composition-api";
export default {
model: {
prop: "value",
event: "zouni"
},
props: {
value: {
type: Number,
default: 0
}
},
setup(props, { emit }) {
const counter = ref(props.value);
const increment = () => {
emit("zouni", ++counter.value);
};
return () =>
h("div", null, [h("button", { on: { click: increment } }, ["plus"])]);
}
};
在容器頁面中:
<el-input v-model="cValue" /> <counter v-model="cValue" />

如果要再結合 TypeScript 來用,改動只有:
- import { defineComponent } from "@vue/composition-api";
- export default defineComponent<IProps>({ 組件 })
單元測試
如果使用了 TypeScript 的強類型加持,組件內外的參數類型就有了較好的保障。
而對于組件邏輯上,仍需要通過單元測試完成安全腳手架的搭建。同時,由于函數式組件一般相對簡單,測試編寫起來也不麻煩。
在實踐中,由于 FC 與普通組件的區(qū)別,還是有些小問題需要注意:
re-render
由于函數式組件只依賴其傳入 props 的變化才會觸發(fā)一次渲染,所以在測試用例中只靠 nextTick() 是無法獲得更新后的狀態(tài)的,需要設法手動觸發(fā)其重新渲染:
it("批量全選", async () => {
let result = mockData;
// 此處實際上模擬了每次靠外部傳入的 props 更新組件的過程
// wrapper.setProps() cannot be called on a functional component
const update = async () => {
makeWrapper(
{
value: result
},
{
listeners: {
change: m => (result = m)
}
}
);
await localVue.nextTick();
};
await update();
expect(wrapper.findAll("input")).toHaveLength(6);
wrapper.find("tr.whole label").trigger("click");
await update();
expect(wrapper.findAll("input:checked")).toHaveLength(6);
wrapper.find("tr.whole label").trigger("click");
await update();
expect(wrapper.findAll("input:checked")).toHaveLength(0);
wrapper.find("tr.whole label").trigger("click");
await update();
wrapper.find("tbody>tr:nth-child(3)>td:nth-child(2)>ul>li:nth-child(4)>label").trigger("click");
await update();
expect(wrapper.find("tr.whole label input:checked").exists()).toBeFalsy();
});
多個根節(jié)點
函數式組件的一個好處是可以返回一個元素數組,相當于在 render() 中返回了多個根節(jié)點(multiple root nodes)。
這時候如果直接用 shallowMount 等方式在測試中加載組件,會出現(xiàn)報錯:
[Vue warn]: Multiple root nodes returned from render function. Render function should return a single root node.
解決方式是封裝一個包裝組件:
import { mount } from '@vue/test-utils'
import Cell from '@/components/Cell'
const WrappedCell = {
components: { Cell },
template: `
<div>
<Cell v-bind="$attrs" v-on="$listeners" />
</div>
`
}
const wrapper = mount(WrappedCell, {
propsData: {
cellData: {
category: 'foo',
description: 'bar'
}
}
});
describe('Cell.vue', () => {
it('should output two tds with category and description', () => {
expect(wrapper.findAll('td')).toHaveLength(2);
expect(wrapper.findAll('td').at(0).text()).toBe('foo');
expect(wrapper.findAll('td').at(1).text()).toBe('bar');
});
});
fragment 組件
另一個可用到 FC 的小技巧是,對于一些引用了 vue-fragment (一般也是用來解決多節(jié)點問題)的普通組件,在其單元測試中可以封裝一個函數式組件 stub 掉 fragment 組件,從而減少依賴、方便測試:
let wrapper = null;
const makeWrapper = (props = null, opts = null) => {
wrapper = mount(Comp, {
localVue,
propsData: {
...props
},
stubs: {
Fragment: {
functional: true,
render(h, { slots }) {
return h("div", slots().default);
}
}
},
attachedToDocument: true,
sync: false,
...opts
});
};
Vue 3 中的函數式組件
這部分內容基本和我們之前在 composition-api 中的實踐是一致的,大致提取一下新官網文檔中的說法吧:
真正的函數組件
在 Vue 3 中,所有的函數式組件都是用普通函數創(chuàng)建的。換句話說,不需要定義 { functional: true } 組件選項。
它們將接收兩個參數:props 和 context。context 參數是一個對象,包含組件的 attrs、slots 和 emit property。
此外,h 現(xiàn)在是全局導入的,而不是在 render 函數中隱式提供:
import { h } from 'vue'
const DynamicHeading = (props, context) => {
return h(`h${props.level}`, context.attrs, context.slots)
}
DynamicHeading.props = ['level']
export default DynamicHeading
單文件組件
在 3.x 中,有狀態(tài)組件和函數式組件之間的性能差異已經大大減少,并且在大多數用例中是微不足道的。因此,在單文件組件上使用 functional 的開發(fā)者的遷移路徑是刪除該 attribute,并將 props 的所有引用重命名為 $props,以及將 attrs 重命名為 $attrs:
<template>
<component
v-bind:is="`h${$props.level}`"
v-bind="$attrs"
/>
</template>
<script>
export default {
props: ['level']
}
</script>
主要的區(qū)別在于:
- 從
<template>中移除functionalattribute listeners現(xiàn)在作為$attrs的一部分傳遞,可以將其刪除
總結
到此這篇關于Vue.js函數式組件的文章就介紹到這了,更多相關Vue.js函數式組件內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Element中table組件按照屬性執(zhí)行合并操作詳解
在我們日常開發(fā)中,表格業(yè)務基本是必不可少的,對于老手來說確實簡單,家常便飯罷了,但是對于新手小白如何最快上手搞定需求呢?本文從思路開始著手,幫你快速搞定表格2022-11-11
vue2.0在沒有dev-server.js下的本地數據配置方法
這篇文章主要介紹了vue2.0在沒有dev-server.js下的本地數據配置方法的相關資料,非常不錯,具有參考借鑒價值,需要的朋友可以參考下2018-02-02
vue-cli對element-ui組件進行二次封裝的實戰(zhàn)記錄
組件類似于需要多個地方用到的方法,在Vue中組件就是一種復用(經常使用)一個功能的手段,下面這篇文章主要給大家介紹了關于Vue?element?ui二次封裝的相關資料,需要的朋友可以參考下2022-06-06

