背景
我們通常會遇到這麼一個場景:有幾個基本功能一樣的元件,但是他們之間又存在着足夠的差異。這時候你就來到了一個岔路口:我是把他們“按部就班”地寫成不同的元件呢?還是保留為一個“公共元件”,然後通過props傳參進行不同功能之間的區分呢?
現在還有一個場景:在一些元件(甚至是項目中全部和某個功能有關的元件)中,有某個功能是相同的。而且都需要利用這個功能進行後續操作。你又需要選擇了,但是這次有一個前提:肯定是要“複用”的 —— 公共元件?還是 mixin ?
這裡其實筆者個人認為并将其分為“css- UI複用”和“功能複用”兩種方式。這裡先按下不提。本文預設講的是後者。
我們現在來分析下:
在第一個場景中,其實兩種解決方案都不夠完美:如果拆分成多個元件,你就不得不冒着一旦功能變動就要在所有相關檔案中更新代碼的風險,這違背了
DRY
原則;反之,太多的 props 傳值會讓代碼變得混亂不堪,後續難以維護、團隊了解困難,效率降低。那有沒有更好的方法?
再來看第二個場景,其實我們很清楚地知道:這時候我們需要的不是一個可以傳值的元件,而是一個類似于插件一樣的 js 代碼(這麼說能夠了解吧)!
使用Mixin吧
Vue 中的 Mixin 對編寫函數式風格的代碼很有用,因為函數式程式設計就是通過減少移動的部分讓代碼更好了解。Mixin 允許你封裝一塊在應用的其他元件中都可以使用的函數。如果使用姿勢得當,他們不會改變函數作用域外部的任何東西。是以哪怕執行多次,隻要是同樣的輸入你總是能得到一樣的值。
如何使用
mixin其實有兩種寫法 ——
Object
和
Function
。
它們都可以在單個元件或者全局中引用。但對于 function 形式的mixin,筆者更推薦将其作為元件級别使用(而非全局的)。
先看第一種寫法:
假設有一對不同的元件,它們的作用是通過切換狀态(Boolean)來展示或者隐藏模态框或提示框。這些提示框和模态框除了功能相似以外,沒有其他共同點:它們看起來不一樣,用法不一樣,但是邏輯一樣。
這時我們可以将它們的公共邏輯部分封裝為一個js檔案:
// mixins目錄下的toggle.js檔案
export const toggle = {
data() {
return {
isShowing: false
}
},
methods: {
toggleShow() {
this.isShowing = !this.isShowing;
}
}
}
一般我們選擇建立一個專門的mixin目錄。在裡面建立一個檔案含有
.js
擴充名,為了使用Mixin我們需要輸出一個對象。(es6 Modules)
然後使用
mixins:[]
的方式引入mixin檔案,(引入後)對象中的屬性可直接使用(就像開頭說的“插件”一樣):
import { toggle } from './mixins/toggle';
//...
const Modal = {
template: '#modal',
mixins: [toggle],
//...
};
const Tooltip = {
template: '#tooltip',
mixins: [toggle],
//...
};
第二種寫法:
這種形式其實就特别适用于開頭說的第二種情況。因為 mixin 内部一個元件該有的它都可以具備。而且上面也說了:當mixin被引入後它内部的東西可以被直接使用 —— 其實就是被merge到引用它的元件中了!(相當于對父元件的擴充)
假如我們請求完要根據資料給出提示并且要給出降級方案(預設提示)。這個需求基本是項目中必不可少而且不止一次出現的。但是像一般情況沒有引用其餘外部UI而且又不是模态框那樣的“通用提示”,放在全局中不太合适。這時候就需要我們的 mixin 出場了:
// mixins目錄下的index.js檔案
function formatRes(res) {
const data = res.data;
if (data.status.code === '表示通過的數') {
return data
} else {
if (判斷是否引入了提示框元件) {
//提示框元件的調用和傳參
}
return data
}
}
var mixin = function (options) {
let defaultData = {}
let defaultMethods = {}
defaultMethods.formatRes = formatRes;
return {
data: function () { //這個會在引用它的元件的data中出現
return defaultData;
},
methods: defaultMethods, //同上,在引用它的元件中可直接通過this.formatRes調用到
}
}
export default
因為是函數形式,是以在引用vue的script開頭應該這麼寫:
import mixin from '../mixin/index'
const mixinCommen = mixin();
//在export default中這麼寫:
mixins: [mixinCommen],
使用:
const res = await this.$http({ //封裝的請求庫
method: 'GET',
url: '請求位址',
params: {
param: {
}
}
})
const {result} = this.formatRes(res) // 使用mixins函數
if(result) {
this.areaList = result
} else {
//...
return false
}
return true
閉包!一方面讓外部函數可以接收參數,另一方面函數内暴露對象的寫法和vue元件中data必須是函數的原理一樣 —— 讓一個地方的修改不影響其餘地方的資料。
合并和沖突
Mixin 中的生命周期的鈎子也同樣是可用的。是以,當我們在元件上應用 Mixin 的時候,有可能會有鈎子的執行順序的問題。預設 Mixin 上會首先被注冊,元件上的接着注冊,這樣我們就可以在元件中按需要重寫 Mixin 中的語句。元件擁有最終發言權。
在vue的源碼中,我們可以很清楚的看到:mergeOptions 會去周遊 mixins ,parent 先和 mixins 合并,然後才去和 child 合并
function mergeOptions(parent, child,) {
if (child.mixins) {
for (var i = 0, l = child.mixins.length; i < l; i++) {
parent = mergeOptions(parent, child.mixins[i], vm);
}
}
//...
}
而對于生命周期來說,vue會把所有的鈎子函數儲存進一個數組。并順序執行(清空這個數組)。
在這裡面,混入對象的鈎子會在元件自身的鈎子之前被調用。如果兩者有重複,則元件的方法将會重寫mixin裡的方法 —— methods、props等等也是一樣!
Mixin還能幹啥?
你有沒有遇到過這樣的場景:有如下代碼結構
父元件0
/ \
父元件 父元件
/ \
子元件A 父元件
\
子元件B
現在要從 子元件A 向 父元件0 傳遞資料,或者說“通信”。你怎麼辦?localStorage?vuex?
抛開使用和學習成本、編輯器代碼智能補全等一系列“外物”,假如一個項目中隻有這一個地方需要跨任意元件傳遞資料,而你卻引入了整個
vuex
。在代碼體積上也是一個不小的增量 —— 而你原本可以避免的。
我突然想到,為什麼我們不能直接操作
vnode
呢?就像這樣:
// 可免費商用,隻要加上下面這句注釋即可
// from mengxiaochen@weidian
export default {
methods: {
dispatch(componentName, eventName,) {
let parent = this.$parent || this.$root;
let name = parent.$options.componentName;
while(parent && (!name || name !== componentName)) {
parent = parent.$parent;
if(parent) {
name = parent.$options.componentName;
}
}
if(parent) {
parent.$emit.apply(parent, [eventName].concat(params))
}
},
}
}
我寫了一段js,這個函數接收三個參數:目标元件的componentName、
emit
的事件名、以及想要傳出去的參數。
你是否還記得在“元件間通信”的方法中有一個鮮為人知的方法:
provide & inject
。它的優勢也是為人诟病的一點就是“使用這兩個API,祖先元件不需要知道哪些後代元件在使用它提供的資料,後代元件也不需要知道注入的資料來自哪裡。”
現在
一定程度上解決了這個問題。
mixin
我們把這個js檔案作為mixin引入 —— 在需要往外傳資料的元件中:
import DataMixin from "xxx.js";
export default {
mixins: [DataMixin],
//...
methods: {
onHandleChangeStock(data) {
this.dispatch('comboEditRoot', 'stock-transfer-send', data); //使用!
},
}
}
然後在某一個祖先元件上,你隻需要在 和
data
屬性同級處增加
componentName
屬性并賦予和第一個參數相同的值,然後在
created
生命周期中監聽事件 即可:
this.$on('stock-transfer-send', (data) => {
console.log('傳出來的資料', data)
this.formData.stockLimit = data;
})
你有沒有發現局限?上面的代碼隻适用于“同支子孫元件傳遞資料給祖先元件”。往任意元件怎麼傳?
由于
mixin
的局限,我們可以先找到一個公共父元件,然後再去找其下的具體子元件:
// 可免費商用,隻要加上下面這句注釋即可
// from mengxiaochen@weidian
function broadcast(_this=this, componentName, eventName,) {
_this.$children.forEach(child => {
var name = child.$options.componentName;
if (name === componentName) {
child.$emit.apply(child, [eventName].concat(params));
} else {
// console.log('child',child.$options.componentName)
broadcast.apply(child, [child, componentName, eventName].concat([params]));
}
});
}
export default {
methods: {
dispatch(componentName, eventName, params, uncle=false, childName="") {
var parent = this.$parent || this.$root;
var name = parent.$options.componentName;
while (parent && (!name || name !== componentName)) {
parent = parent.$parent;
if (parent) {
name = parent.$options.componentName;
}
}
if (parent) {
if(uncle) {
console.log('parent', parent)
broadcast(parent, childName, eventName, params);
} else {
parent.$emit.apply(parent, [eventName].concat(params));
}
}
},
// 父元件傳遞資料給人以一個子元件
broadcast(componentName, eventName,) {
broadcast.call(this, componentName, eventName, params);
},
}
};
改寫後的
dispatch
方法就達到了這一效果。而單獨使用
broadcast
則是從父元件傳出資料給某一個子元件。
!注意:上面說的“任意”是在使用效果來看。而對于開發過程中,“任意”是指你可以随意将componentName插到某一個元件中去。
有了上面的實踐,我突然覺得能夠繼續完成之前的一個暢想:有一個方法能夠在不深入侵入業務代碼的同時完成任意元件關聯的功能。即:資料互通。
讓我們改寫一下
dispatch
方法:
// 可免費商用,隻要加上下面這句注釋即可
// from mengxiaochen@weidian
function broadVal(_this=this, componentName,) {
_this.$children.forEach(child => {
var name = child.$options.componentName;
if (name === componentName) {
return child[propName];
} else {
broadVal.apply(child, [child, componentName, propName].concat([params]));
}
});
}
export default {
methods: {
focusWatchData(componentName="", childName="", propName) {
let parent = this.$parent || this.$root;
let name = parent.$options.componentName;
if(componentName) {
while(parent && (!name || name !== componentName)) {
parent = parent.$parent;
if(parent) {
name = parent.$options.componentName;
}
}
} else {
parent = this;
}
if(parent) {
if(!childName) {
return parent[propName];
}
let propVal = broadVal(parent, childName, propName);
return propVal;
}
}
}
};
focusWatchData
函數接收三個參數:父元件 componentName(為空表示從目前元件往下找)、子元件 componentName(為空表示隻往上找)、以及 propName(要擷取的
data
中的屬性名)
同樣将此js檔案以mixin引入在某個元件(被觸發方)中,然後在想要關聯的元件(觸發方)中插入 componentName 即可。
如果你的param是多個值,請使用代替
call
使用!
apply
【更新· 優化】
優化樹形結構查找
可以看到上面“任意元件傳值”和“父元件向子元件傳值”是采用「遞歸」元件寫法。能不能優化呢?
可能你第一時間想到遞歸中的“尾遞歸”。首先,上面已經使用了這種方式。其次,在 js 中并不能使用尾遞歸:
Proper tail calls have been implemented but not yet shipped given that a change to the feature is currently under discussion at TC39. —— V8引擎官方團隊
然後筆者想到了著名的“二叉查找樹”。很可惜,樹的查找方式也是遞歸,不過是二叉查找樹在樹結構範圍内效率更高。
偶然間突然想到,能不能在周遊第一層結構的時候,往下查找第二層,如果有第二層,就把它加到正在周遊的數組中,以試圖讓數組“一直”周遊下去?
不能的。因為在
for
循環形成的閉包中,是不能動态更改引用元素(被周遊的元素實際改變了,但是周遊這一行為仍然終止在其剛開始周遊時的
length
那)。更好了解的說你可以了解為C語言中的“形參和實參”。
但順着這個思路,筆者緊接着想到:能不能用一個“很大”的數字去周遊,在裡面拿到已經改變了的元素的子元素:
function flag(arr) {
let result = []
let originArr = JSON.parse(JSON.stringify(arr));
for (let i=0; i< 100000; i++) {
let item = originArr[i];
console.log('1',item, item.children instanceof Array, item.children.length, originArr, originArr.length);
if (item.children && item.children instanceof Array && item.children.length > 0) { // 如果目前child為數組并且長度大于0,才可進入flag()方法
originArr = originArr.concat(item.children);
delete item['children'];
}
result.push(item)
}
return result
}
其中 arr 是這樣的結構:
const arr = [
{ xxx: xxx, children: [{xxx: xxx, children: []}] },
{ xxx: xxx, children: [] },
{ xxx: xxx, children: [] },
];
恰好,vue就是這樣一顆🌲!
我們唯一需要注意的是,讓其在該結束時及時結束。不然對造成的空間和性能浪費來說,又為什麼要替換掉「遞歸」呢?
拿上面的
broadVal
函數來說,可以這麼改造:
// 可免費商用,隻要加上下面這句注釋即可
// from mengxiaochen@weidian
function broadVal(_this=this, componentName,) {
let originArr = JSON.parse(JSON.stringify(_this));
for (let i=0; i< 100000; i++) {
let item = originArr[i];
if (item.$children && item.$children instanceof Array && item.$children.length > 0) { // 如果目前child為數組并且長度大于0,才可進入flag()方法
if(item.$options.componentName && componentName === item.$options.componentName) {
return item[propName];
}
originArr = originArr.concat(item.$children);
delete item['children'];
}
}
}