Vue 組件設計
Vue 作為 MVVM 框架一員,不管是寫業務還是基礎服務,都少不了書寫組件。本文總結一下書寫業務組件的一些心得。
為什么要寫組件?
我們知道,只要是組件,就需要在引用的時候與 view 或者其他組件進行相關的交互,即 props 傳值,$emit 觸發事件,
使用 $refs 調用組件方法等,與寫在同一個文件相比,耗費的精力明顯更多。那為什么需要拆分出組件呢?我認為有兩種目的:
復用和隔離。
復用
在業務代碼中,會有大量類似的界面,保證交互唯一,即使我們有了類似 element-ui 或者 iview 這種基礎組件庫,
我們同樣需要為這些基礎組件添加 props 或者 events,只有一處使用時,沒有任何問題,當你的業務中出現兩次、三次甚至更多時,
代碼中會出現大量重復的代碼,而且這些代碼在線上可能會慢慢露出一些深層次的 bug,要修復這些 BUG,就需要 n 倍的時間去寫同樣的代碼,
讓人抓狂。所以我們頁面同樣需要像 js 抽出公用的方法一樣抽出公用組件,這就是復用的目的。
隔離
復用針對的是代碼重復問題,而隔離則是針對代碼邏輯過於復雜的問題。通常我們要實現一個復雜的邏輯,它是一個扁平化的多邏輯並行問題,
人腦對於同時思考是有一定限制的,過於復雜就很難一下考慮全面。
首先需要抽象出它的目的,然后對實現進行分層,讓每一層只解決一個簡單的問題,這些層合起來形成一個完整的解決方案;
或者將問題拆分成幾塊,每塊之間具有一定的聯系,每次思考時只需要考慮局部的邏輯即可。
不管是分層、分塊還是混合式,它的目的是對進行隔離,從而簡化問題。如果某個頁面 js + template 行數非常多(1k+),
這個時候就可以考慮是不是要對部分功能拆解,便於在后續添加新功能,或者修改 BUG 的時候更為方便定位到問題的代碼,
不會出現改錯函數的問題。
需要注意的是,雖然復用和隔離是讓邏輯更為清晰,但使用自己寫的組件會讓項目的入手難度提高,需要先了解整體的設計,
才能針對性的修改代碼或者添加新的功能,得失各半。
組件設計的一些理念
網上有關於組件設計的基本原則:http://www.fly63.com/article/detial/996,
內容比較多,下面進行一些常用的原則歸納。
單一職責
之前提到的組件拆分目的:復用與隔離,對於隔離的類型,組件業務必然很重,此時雖然要保證組件盡可能簡單,
而復用類型的,通用性更強,所以功能越單一,使用起來就越方便。我們知道 react 有一個概念:container/component,
即 component 只是渲染組件,而 container 才是產生業務的組件,我們 Vue 也可以依照這個理念進行設計。
即把數據處理等帶有副作用的工作放在父組件中,而子組件只進行展示或操作,通過事件的方式讓父組件進行處理,
保證邏輯歸一,后續維護也更為方便。或者使用 slot 等類似高階組件的方式來簡化當前組件的內容。
無副作用/引用透明
和純函數類似,設計的一個組件不應該對父組件產生副作用,從而達到引用透明(引用多次不影響結果)。
數據操作前必須進行復制。比如需要添加額外的鍵值,或者需要對數組類型的數據進行操作,會對原始數據產生影響,
需要使用解構的方式進行復制:
const newData = { ...oldData }
const newList = [...oldList]
注:引用類型的 props 千萬不要直接修改對象,雖然能夠達到傳遞數據的目的,但會產生副作用,如果有其他地方用到該數據,可能產生未知的影響。
入口和出口正確性檢查
Vue 提供了類型檢查工具,只在 dev 情況下生效,雖然和 JSON Schema 相比功能比較少,但能夠做基本的類型檢查了,
我們只需要在 props 時不使用字符串型,而是為它定義詳細的類型, 並為它設置默認值(vue-cli 的 eslint 嚴格模式已經強制要求):
['name1'] // 不規范寫法
{
name1: {
type: String,
default: undefined
}
}
組件划分顆粒度
組件拆分出來之后,拆成幾層或者是拆成幾塊,影響文件的數量。如果層級比較多,各種 props 傳遞,事件傳遞,維護成本比較高。
舉例:如果是一個二級的列表,即有多個一級列表,一級列表各有一級列表,這個時候應該怎么拆分呢?
按單一原則,我們可能需要拆分成以下幾個:一級列表卡片本身,二級列表卡片,二級列表承載組件,一級列表承載組件。
這種划分,組件是三級,兩塊,數據的傳遞就會比較困難。如果一級卡片列表不復雜,我們可以將幾個 v-for 與組件本身合並,
即一級列表承載組件+一級列表卡片+二級列表卡片,二級列表卡片。這種處理方式保證所有的數據處理在第一層上,二級卡片只做渲染,
保證邏輯處理集中在一個組件,維護也比較方便。當然,如果一級卡片非常復雜,或者數據需要大量的處理,需要根據情況把最細的進行合並。
新功能下添加新屬性/新文件
對於通用類型組件,我們要求它盡可能的短小精悍,調用起來更為簡單,所以不能設計太多的參數。基礎組件庫不能符合這個要求,
主要是因為基礎組件庫需要盡可能增加普適性,不會因為沒有某個常用的屬性,導致該組件需要復制一份重寫,再加上日積月累的 pull request,
屬性和參數必然會越來越多。而我們在業務中使用,完全不需要這么多的配置,如果有重大差別,重新復制一份,對於后續的維護反而更方便。
所以是否新增加屬性還是拷貝一份,是根據后續該組件是否會產生比較大的發展方向差異來決定的。
Vue 組件之間的交互設計
Vue 組件與 React 組件有比較大的區別,模板的設計更偏向於 HTML,所以要實現類似 react 的高階組件的需求通常比較少,
而高階組件集成度過高,對於業務來說,當業務越來越復雜,組件內部邏輯將拆分困難,未必是件好事,所以我們只討論普通的組件設計。
組件設計是考慮組件通訊方式,主要分為以下幾個方面:向下傳值,向上傳值,偽雙向綁定,方法調用。
數據流轉
向下傳值
向下傳值就是父級傳給子級數據。前面已經提到了,在 props 傳值盡量對傳入數據進行類型校驗,保證盡快發現問題。除此之外,也有一些注意事項。
傳值類型如果是引用類型的 Object 類型,那么盡量給它默認值,防止 undefined。
default: () => ({})
其次,父級在賦值時,不要使用 a=newData
這種寫法,而是使用 Object.assign
來保證能准確觸發組件更新。
還有另外一種方式,但不方便聲明所有對象內的數據時,可以使用 this.$set(this, 'key', newData)
,保證對象一定會被監聽到。
向上傳值
Vue 2.0 需要使用 $emit
進行事件向上冒泡, 父組件進行事件的監聽就可以進行處理。
偽雙向綁定
Vue 2.0 提供了語法糖,支持雙向綁定,使得Vue 進行雙向傳遞數據極為方便,不需要既向上傳值又向下傳值。
當然它不是真正的綁定,而是封裝了之前提到的向下傳值和向上傳值,簡單的語法糖。它分為兩類:v-model 和 .sync 修飾符
數據傳遞支持各種類型,不過建議傳遞的數據使用數組而不要使用對象類型,對象類型可能會出現渲染監聽失敗的問題。
v-model
v-model 使用的是 value
屬性和 input
事件,父組件會自動把 input 事件的值賦給對應的變量。
在設計組件中,如果有雙向的數據傳遞,且符合組件設計目的,應該優先使用 v-model 來實現數據的控制,
這樣的組件更符合 Vue 組件的標准。
要注意的是,如果是自行寫 render 函數,雙向綁定要自己實現。
sync
.sync
修飾符和 v-model 比較類似,不過它的 props 可以是自定義的,而向上傳值時方式為:
this.$emit('update:propsName', val)
本質上和 v-model 是類似的。sync 修飾符相比於 v-model,語義化更好,用起來更方便
方法調用
有了 props 和 emit ,我們已經基本能夠實現大部分功能了,但總有些子組件的層次控制或者數據控制無法通過這種方式實現,
這個時候,組件間的交互就需要使用子組件的 Methods 來定義,使用 this.$refs.組件ref
來調用它的方法。
比如說 el-tree 組件,設置選中和非選中,只靠數據傳遞,無法保證設計選中狀態,所以它提供了一些方法來進行手動選擇。
在設計組件時,使用方法進行控制應該是最后才考慮的,因為我們通常無法一眼看出某個方法是否應該支持外部調用,
只能通過看文檔才能得知相關的方法
簡化與抽離的其他實現
除組件外,Vue 提供了一些機制用於減少項目中的代碼重復率。
使用插件或者 mixins 實現
插件機制需要在 Vue 初始化的時候引入。看下 vue-meta 的插件入口寫法:
/**
* Plugin install function.
* @param {Function} Vue - the Vue constructor.
*/
export default function VueMeta (Vue, options = {}) {
// set some default options
const defaultOptions = {
keyName: VUE_META_KEY_NAME,
contentKeyName: VUE_META_CONTENT_KEY,
metaTemplateKeyName: VUE_META_TEMPLATE_KEY_NAME,
attribute: VUE_META_ATTRIBUTE,
ssrAttribute: VUE_META_SERVER_RENDERED_ATTRIBUTE,
tagIDKeyName: VUE_META_TAG_LIST_ID_KEY_NAME
}
// combine options
options = assign(defaultOptions, options)
// bind the $meta method to this component instance
Vue.prototype.$meta = $meta(options)
// store an id to keep track of DOM updates
let batchID = null
// watch for client side component updates
Vue.mixin({
beforeCreate () {
// Add a marker to know if it uses metaInfo
// _vnode is used to know that it's attached to a real component
// useful if we use some mixin to add some meta tags (like nuxt-i18n)
if (typeof this.$options[options.keyName] !== 'undefined') {
this._hasMetaInfo = true
}
// coerce function-style metaInfo to a computed prop so we can observe
// it on creation
if (typeof this.$options[options.keyName] === 'function') {
if (typeof this.$options.computed === 'undefined') {
this.$options.computed = {}
}
this.$options.computed.$metaInfo = this.$options[options.keyName]
}
},
created () {
// if computed $metaInfo exists, watch it for updates & trigger a refresh
// when it changes (i.e. automatically handle async actions that affect metaInfo)
// credit for this suggestion goes to [Sébastien Chopin](https://github.com/Atinux)
if (!this.$isServer && this.$metaInfo) {
this.$watch('$metaInfo', () => {
// batch potential DOM updates to prevent extraneous re-rendering
batchID = batchUpdate(batchID, () => this.$meta().refresh())
})
}
},
activated () {
if (this._hasMetaInfo) {
// batch potential DOM updates to prevent extraneous re-rendering
batchID = batchUpdate(batchID, () => this.$meta().refresh())
}
},
deactivated () {
if (this._hasMetaInfo) {
// batch potential DOM updates to prevent extraneous re-rendering
batchID = batchUpdate(batchID, () => this.$meta().refresh())
}
},
beforeMount () {
// batch potential DOM updates to prevent extraneous re-rendering
if (this._hasMetaInfo) {
batchID = batchUpdate(batchID, () => this.$meta().refresh())
}
},
destroyed () {
// do not trigger refresh on the server side
if (this.$isServer) return
// re-render meta data when returning from a child component to parent
if (this._hasMetaInfo) {
// Wait that element is hidden before refreshing meta tags (to support animations)
const interval = setInterval(() => {
if (this.$el && this.$el.offsetParent !== null) return
clearInterval(interval)
if (!this.$parent) return
batchID = batchUpdate(batchID, () => this.$meta().refresh())
}, 50)
}
}
})
}
要的本質是使用 prototype 設置獨立變量,然后使用 mixins 注入相關的方法。可以看到,基本上每個生命周期都會處理到。
mixin 不僅使用在插件中,直接使用也是可以的。關於 mixins 可看官方文檔:https://cn.vuejs.org/v2/guide/mixins.html.
事件與屬性透傳
之前提到組件盡可能參數少,但參數過少,組件無法實現某些定制化的要求,而我們組件可能有多個層次,
這種情況下我們需要將當前組件的父組件的其他屬性透傳給子組件,將父組件其他事件監聽給子組件,寫法如下:
<div name="main">
<input v-on='$listeners' v-bind="$attrs" />
</div>
其他注意事項
DOM 操作
正常情況下是不推薦業務組件直接操作 DOM 的,但有時候要寫組件監聽事件,這種情況下一定要注意在 destroyed 時候進行 removeEventListener。