vue程式設計式元件應用
下文會將使用宣告式渲染的元件稱為常規元件 (元件內包含模板語法),而使用渲染函式或JSX的元件則稱為程式設計式元件
在介紹程式設計式元件 之前,有必要了解一下宣告式渲染 。如果把常規元件認為是模組化開發的某種解決方案,那麼程式設計式元件就是另一種介於宣告式和命令式之間更為靈活、簡潔的方案。
在vue中,宣告式渲染首當其衝。何為宣告式渲染?在我看來,就是使用預先定義的規則來構建DOM結構,實現檢視的標準化輸出,包括人機互動與視覺效果。如同一個方法,規定其只要傳入某些引數,內部便能根據引數和邏輯規則,輸出一段轉換結果。如果沒有引數,只在方法內部按照需求硬式編碼,即可認為,這是背離了模組化開發思想的命令式渲染。
一個良好的宣告式渲染體系,帶來的好處是顯而易見的。不需要理解渲染的實現細節,只要遵循簡化後的模板語法即可完成大量的檢視輸出工作,再結合資料雙向繫結機制,只要少量程式碼就形成了資料從輸入到輸出的一個相對穩定的工作閉環(資料狀態即檢視狀態)。從另外一個方面來看,當下,前端模組化開發模式最大的優勢就是,由於專案的組成單元不再是以前的頁面,而是元件(與頁面一樣擁有獨立的作用域,對於大型專案,可採用多頁模式,即一個元件群構成一個單頁應用,多個單頁應用再構成一個完整的專案),可以更好的分解專案模組,利於多人協同開發,並提高了程式碼的可重用性和穩定性。試想一下,如果沒有宣告式渲染,這也許就是一場噩夢的開始。沒有簡潔的模板語法與高效能的渲染方法支援,分解出來的模組不但無法重用,而且還臃腫不堪,穩定性及執行效率差,反而增加了開發難度和工作強度。
但任何事物都有兩面性,宣告式渲染同樣有其無法規避的弊端。從字面上來解釋,宣告式渲染就是顯式、可見的檢視渲染方式。常規元件的呼叫就如同使用變數之前,需要預先定義變數的型別和初始值一樣,這種“類靜態式”處理便大幅壓縮了元件應用場景的可變空間,不僅如此,這種堆砌式的開發模式也會帶來不同程度的結構臃腫問題。我們都知道,以非侵入方式改變一個有參方法的輸出,通常是按照引數規格傳入引數。在這裡,可以將引數分為兩類,資料和(回撥)方法,如果你認同這是一種輸入,可將其分為資料輸入和(被執行的)程式碼輸入。由於單純的資料輸入不能做到反向輸出,一般情況下元件要改變呼叫方的行為或輸出需要使用事件機制(間接的程式碼輸入)。例如,在父元件中,繫結一個控制代碼處理方法,而後由子元件在恰當的時刻通知父元件執行事件方法,從而完成某種意義上的反向輸出。可是,如果存在多個反向輸出且需要進行事務控制的時候(異常和正常處理需要在同一作用域下統一排程甚至回退),那麼以多線輸出方式(多個事件即存在多線輸出)解決集中排程問題,會導致程式的不穩定因素增多,結果往往會不勝其擾。
常規元件相比程式設計式元件,無法做到真正意義上的深度包裝,在渲染方式上也存在一定的侷限性。例如,如果要對內部子元件的引數和事件進行繫結,必須預先約定,外部引數必須在結構和型別上與之一一吻合。
常規元件示例(定義元件部分):
<template> <el-button :type="type" :icon="icon" @click="onClick"> {{content}} </el-button> </template> <script> export default { name: 'my-button', props: { type: { type: String, default: 'primary' }, icon: { type: String, default: 'el-icon-search' }, content: { type: String, default: '宣告式渲染元件' } }, methods: { onClick() { this.$emit('click') } } } </script>
常規元件示例(呼叫元件部分):
<template> <my-button :type="button.type" :icon="button.icon" @click="onClick"></my-button> </template> <script> import MyButton from './MyButton' export default { components: { MyButton }, data() { return { button: { type: 'success', icon: 'el-icon-check' } } }, methods: { onClick() { console.log('click...') } } } </script>
注意,元件el-button其實還有plain、round和circle等屬性可以改變外觀樣式,如果使my-button同樣具有這些引數入口,則需要修改my-button的引數定義部分,並在元件內部增加新的引數繫結。但程式設計式元件則可以一次性輸入引數。
程式設計式元件示例(定義部分):
<script> // 不再需要模板部分,覆寫元件的render方法即可 export default { name: 'my-button', props: { data: { type: Object, default() { return { props: { type: 'primary', icon: 'el-icon-search' } } } } }, render(h) { // 形參h是必須的,vue jsx 會將其翻譯成 h(...) // 這裡將data引數展開,el-button所能識別的屬性都能被正確繫結,包括事件 // this.$slots.default 獲取插槽中的內容 return (<el-button { ...this.data }>{this.$slots.default}</el-button>) } } </script>
程式設計式元件示例(呼叫部分):
<template> <my-button :data="buttonData">程式設計式元件</my-button> </template> <script> import MyButton from './MyButton' export default { components: { MyButton }, data() { return { buttonData: { props: { type: 'success', icon: 'el-icon-check', // 注意,my-button內部data引數的預設值並不包括此屬性,但仍然可以傳入 plain: true }, on: { click: () => { this.onClick() } } } } }, methods: { onClick() { console.log('click...', this) } } } </script>
將以上的兩個示例對比一下,從中會發現,程式設計式元件的渲染更為靈活與簡便,更易於進行深度包裝。但程式設計式元件的優點遠不止如此,如果你的專案中大量使用對話方塊,以el-dialog為例,按照宣告式語法,如果不對元件進行一定程度的包裝,那麼要使用該元件,則需要在每個需要彈出對話方塊的元件內部植入這樣的程式碼:
... <el-dialog :visible.sync="dialogVisible"> <!-- 對話方塊內容大多數情況下應該是一個新的元件 --> </el-dialog> ...
我們先使用常規元件包裝一下el-dialog,程式碼如下:
<template> <el-dialog :title="title" :width="width" :visible.sync="dialogVisible"> <!-- 內容使用插槽來接收 --> <solt></solt> <span slot="footer" class="dialog-footer"> <el-button @click="cancel">取 消</el-button> <el-button type="primary" @click="ok">確 定</el-button> </span> </el-dialog> </template> <script> export default { name: 'my-dialog', // 如果el-dialog要引入新的屬性,除了宣告式繫結,還用在此處增加引數定義 props: ['title', 'width'], data() { return { dialogVisible: false } }, methods: { cancel() { this.dialogVisible = false console.log(this.$solts.default) }, ok() { this.dialogVisible = false console.log(this.$solts.default) }, show() { this.dialogVisible = true }, hide() { this.dialogVisible = false } } } </script>
... <!-- 呼叫示例 --> <!-- content-view 是一個假設 --> <my-dialog ref="dialog"><content-view/></my-dialog> <el-button @click="showDialog"> 程式碼測試 </el-button> ... <script> export default { methods: { showDialog() { this.$refs.dialog.show() } } } </script>
my-dialog雖然從一定程度上簡化了對話方塊的操作,但要使el-dialog的所有特性暴露在my-dialog元件上,其定義過程將相當繁瑣。而且更重要的是在元件的使用上並沒有多大的改觀,仍然需要四處在對應的元件內植入模板定義,這樣做破壞了結構的簡潔性和程式碼的可讀性,特別是在多層巢狀使用對話方塊的情況下,要羅列出程式所要表述的事務流程,無疑會讓人感到很困惑。
如果有一個方法以非同步請求的方式來呼叫對話方塊,是否會為之眼前一亮呢?
<template> <div> <span>使用程式設計式元件封裝的對話方塊元件</span> <el-button @click="onClick">新增</el-button> </div> </template> <script> // 內容元件 import AddUnit from './AddUnit' // 引入方法 import { showDialog } from '@/utils' export default { methods: { onClick() { showDialog({ components: { AddUnit }, props: { title: '新增單元' } }).then(resp => { if (resp.ok) { // 確定處理 console.log(resp.ok.data) } if (resp.cancel) { // 取消處理 console.log(resp.cancel.data) } }) } } } </script>
showDialog方法不僅簡化了元件的定義與呼叫過程,更優化了資料的傳遞方式,做到了事務性控制,將程式設計式元件的優勢體現得淋漓盡致。
/** * 對話方塊(基於element-ui) * @author chenwen * @version 1.0.0.2 * @file dialog.js */ import Vue from 'vue' // 私有方法的符號物件 const _parse = Symbol('#parse' + new Date().getTime()) // DOM id字首 const PREFIX = '_DLG_', // 預設對話方塊頁尾配置 DEFAULT_FLOOR = { cancelTxt: '取消', okTxt: '確定', class: 'dialog-footer' } // DOM編號(自增) let ID = 0 // 為內容元件預置的介面方法名稱,分別對應確定或取消 // 內容元件內部按照約定義好方法後,對話方塊物件在收到確定或取消指令後呼叫元件方法(單擊確定或按鈕) export const CANCEL_SYM = 'cancel$dialog', OK_SYM = 'ok$dialog', // 用於取消關閉時的返回標誌,由使用者鉤子方法(確定或取消)返回 // 為了確定該標誌的唯一性,使用符號型別來定義 CANCEL_CLOSE = Symbol('_CANCEL_CLOSE_') /** * 開啟對話方塊(私有方法) * 檢查由構造器傳入的配置選項中是否存在對應的鉤子方法,存在則呼叫並傳入對話方塊物件(this) */ function onOpen() { const { onOpen: _open } = this.options _open && _open(this) } /** * 關閉對話方塊 */ function onClose() { const { onClose: _close } = this.options _close && _close(this) } /** * 開啟對話方塊動畫完成之後 */ function onOpened() { const { onOpened: _opened } = this.options _opened && _opened(this) } /** * 關閉對話方塊動畫完成之後 * 執行分為: * 1. 呼叫自定義鉤子方法並傳入對話方塊物件 *由呼叫方決定是否要使用返回結果,this中包含兩個資料,this.data, this.getContent().$data * 2. 檢查是否為非同步呼叫,否,直接銷燬物件,是,則傳入結果資料,傳送非同步呼叫訊息通知呼叫方接收 this.done(...) *確定或取消時,會指定結果到this.response中,例如{ok: ...}或{cancel: ...} *如果確定或取消鉤子方法未定義則寫入{close: ...},返回結果優先取鉤子方法返回的結果,否則為內容元件的data屬性 *這種情況通常出現在自定義或取消了對話方塊頁尾的情況下, *而頁尾中按鈕會預置確認和取消的單擊事件,這個時候需要在自定義渲染頁尾的同時繫結內建的事件方法 */ function onClosed() { const { onClosed: _closed } = this.options _closed && _closed(this) if (!this.done) { this.destory() } else { if (!this.response) { this.response = { close: { contentData: this.getContent.$data, dialogData: this.data } } } this.done(this.response) } } /** * 內建的取消方法 * 執行分為 * 1. 呼叫自定義鉤子方法,分為兩種,內容元件內部定義或配置選項中定義 *注意:會在鉤子方法中傳入CANCEL_CLOSE,可在鉤子方法內部需要取消關閉時返回 * 2. 如果鉤子方法返回的是一個約定(Promise),則在響應之後將接收結果寫入response,否則直接寫入 * 3. 關閉對話方塊(關閉之後會觸發兩個事件,close和closed) */ function onCancel() { // 已配置引數傳入的鉤子方法必須是箭頭函式 const { onCancel: _cancel } = this.options, content = this.getContent(), outHandler = _cancel, innerHandler = content[CANCEL_SYM] && (() => content[CANCEL_SYM](CANCEL_CLOSE)), handler = outHandler || innerHandler, result = handler && handler() this.response = { cancel: { contentData: content.$data, dialogData: this.data } } if (result !== CANCEL_CLOSE) { if (result instanceof Promise) { result.then(data => { this.response.cancel.data = data this.close() }) } else { this.response.cancel.data = result this.close() } } } /** * 內建的確定方法 * 執行邏輯同上 */ function onOk() { const { onOk: _ok } = this.options, content = this.getContent(), outHandler = _ok, innerHandler = content[OK_SYM] && (() => content[OK_SYM](CANCEL_CLOSE)), handler = outHandler || innerHandler, result = handler && handler() this.response = { ok: { contentData: content.$data, dialogData: this.data } } if (result !== CANCEL_CLOSE) { if (result instanceof Promise) { result.then(data => { this.response.ok.data = data this.close() }) } else { this.response.ok.data = result this.close() } } } class Dialog { /** * 對話方塊構造器 * @param { Object } options 配置選項 * @param { Promise<T> } resolve 非同步通知 */ constructor(options = {}, resolve) { // 為了使對話方塊物件跳出複雜的巢狀物件鏈,成為獨立的vue例項 // 建立之前,會在body的底部插入一個擁有惟一標識的div,使其成對話方塊例項渲染的父容器 // 每個對話方塊例項都有獨立的資料和檢視空間 this.domId = PREFIX + ID++ document.body.appendChild(document.createElement('div')).id = this.domId // 用於控制對話方塊的顯示 this.data = { visible: false } this.options = options // 構建vue例項並掛載到對應的DOM容器上 this.vm = new Vue(this[_parse](options)).$mount(`#${this.domId}`) // 如果是非同步呼叫,則生成一個完成後的通知方法,並最終執行銷燬方法 if (resolve) { this.done = data => Promise.resolve(resolve(data)).finally(this.destory()) } } /** * 解析配置選項(私有方法) * @param {Object} param0 配置選項 * 格式說明: * { *// 包含一個或多個需要內部註冊元件,渲染函式將可能使用這些元件 *// 也可以是對話方塊內容區域的文字內容 *components?: { [name: string]: Component } | string; *// 自定義渲染方法 建議採用JSX輸出 *render?: (h, dialog) => [VNode, ...]; *// 對話方塊屬性 *props?: { [name: string]: any }; *// 當對話方塊內容為動態渲染時(未使用預定義的內容元件),此屬性對應動態內容元件的data部分 *data?: { [name: string]: any }; *// 對話方塊的class樣式 *class?: { [className: string]: Boolean } | string; *// 標題欄 *title?: { render: (h, dialog) => [ VNode, ...]} *// 對話方塊頁尾 *floor?: Boolean | *{ *cancelTxt?: string; // 取消按鈕標題 預設“取消” *okTxt?: string;// 確定按鈕標題 預設“確定” *class?: string;// class樣式 預設“dialog-footer” *// 自定義頁尾渲染方法,defaultButton將在被呼叫時傳入, *// 其中包含兩個預設按鈕(確定或取消)的渲染方法,由自定義方法選擇性呼叫, *// 預設的渲染方法會繫結內建的單擊事件方法(onOk和onCancel) *render?: (h, defaultButton) => [ VNode,... ] *} * } */ [_parse]({ components = {}, data = {}, render, props = {}, class: clazz, title, floor }) { const __this = this this.data = Object.assign(this.data, data) if (floor !== false) { if (floor instanceof Object) { floor = Object.assign({}, DEFAULT_FLOOR, floor) } else { floor = Object.assign({}, DEFAULT_FLOOR) } } const _data = { class: clazz, props, on: { 'update:visible': value => (this.data.visible = value), close: _ => onClose.call(this), open: _ => onOpen.call(this), closed: _ => onClosed.call(this), opened: _ => onOpened.call(this) }, attrs: { id: this.domId } }, defaultBotton = { okHandler: _ => onOk.call(this), cancelHandler: _ => onCancel.call(this), // 對於渲染方法來說,必須傳入createElement方法(h) // 因為vue loader 會將jsx部分翻譯成使用createElement渲染元件 // 雖然字面上h形參並沒有用,但是編譯後的程式碼會呼叫h(...)來執行渲染 // 如果呼叫此類渲染方法沒有定義並傳入h,那麼會丟擲h方法未定義錯誤 cancelBtn: h => ( <el-button {...{ on: { click: _ => onCancel.call(this) } }}> {floor.cancelTxt} </el-button> ), okBtn: h => ( <el-button type="primary" {...{ on: { click: _ => onOk.call(this) } }} > {floor.okTxt} </el-button> ) } return { components, data() { return __this.data }, render: h => { return ( <el-dialog {..._data} visible={this.data.visible}> {title && title.render ? ( <span slot="title">{title.render(h, this)}</span> ) : ( undefined )} {render(h, this.data, this)} {floor !== false ? ( floor.render ? ( <span slot="footer" class={floor.class}> {floor.render(h, defaultBotton)} </span> ) : ( <span slot="footer" class={floor.class}> <el-button {...{ on: { click: _ => onCancel.call(this) } }}> {floor.cancelTxt} </el-button> <el-button type="primary" {...{ on: { click: _ => onOk.call(this) } }} > {floor.okTxt} </el-button> </span> ) ) : ( undefined )} </el-dialog> ) } } } /** 獲取內容元件 */ getContent() { // 預設插槽對應的元件例項即為內容元件 return this.vm.$children[0].$slots.default[0].componentInstance || {} } /** 顯示對話方塊 */ show() { this.data.visible = true } /** 關閉對話方塊 */ close() { this.data.visible = false } /** 銷燬對話方塊 */ destory() { this.vm.$destroy() document.body.removeChild(document.body.querySelector(`#${this.domId}`)) } } // 匯出對話方塊(class型別) export { Dialog } // 元件名稱應為帕斯卡或駝峰命名,除字母數字外不能包含其他字元 export const pascal2Kebab = name => { name = name.charAt(0).toUpperCase() + name.substr(1) return name .match(/[A-Z][a-z0-9]*/g) .reduce((r, v, i) => (i === 0 ? v : r + '-' + v)) .toLowerCase() } /** * 對話方塊呼叫方法 * @param { Object } options 配置選項 * @param { Boolean } async 是否非同步呼叫 預設值true */ export const showDialog = (options, async = true) => { const { components = {}, render } = options // 當渲染方法未定義時 if (!render) { // 如果元件引數為字元內容時直接輸出 if (typeof components === 'string') { options.components = {} options.render = () => { return components } // 如果components包含元件物件,則直接輸出對應元件標籤名的JSX內容 } else if (components instanceof Object) { let tag = Object.keys(components)[0] if (tag) { // 將帕斯卡命名轉換為短橫線命名 tag = pascal2Kebab(tag) options.render = h => { return <tag /> } } } } if (async) { // 非同步呼叫對話方塊,接收資料使用then(resp => ...) return new Promise(resolve => { new Dialog(options, resolve).show() }) } // 一般呼叫 new Dialog(options).show() }
其實showDialog還有另一種更為靈活的呼叫方式。下面的呼叫示例,在render方法內使用JSX動態建立了一個表單檢視,並在關閉時,將使用者輸入的表單資料返回給呼叫者。
<script> ... onClick() { showDialog({ data: { form: { name: '', region: '' } }, render: (h, data) => { const form = data.form return ( <el-form vModel={form}> <el-form-item label="活動名稱" label-width="120px"> <el-input vModel={form.name} autocomplete="off"></el-input> </el-form-item> <el-form-item label="活動區域" label-width="120px"> <el-select vModel={form.region} placeholder="請選擇活動區域"> <el-option label="區域一" value="shanghai"></el-option> <el-option label="區域二" value="beijing"></el-option> </el-select> </el-form-item> </el-form> ) } }).then(resp => { console.log((resp.ok || resp.cancel).dialogData.form) } ) } ... </script>
最後,強調一下,頁尾區域和標題區域,都支援自定義渲染。相關使用規則請參考dialog.js相應的註釋內容。