封裝Vue元件的一些技巧

橙紅年代發表於2019-04-15

本文同步在個人部落格shymean.com上,歡迎關注

寫Vue有很長一段時間了,除了常規的業務開發之外,也應該思考和反思一下封裝元件的正確方式。以彈窗元件為例,一種實現是在需要模板中引入需要彈窗展示的元件,然後通過一個flag變數來控制彈窗的元件,在業務程式碼裡面會充斥著冗餘的彈窗元件邏輯,十分不優雅。

本文整理了開發Vue元件的一些技巧,包含大量程式碼示例。

開發環境

vue-cli3提供了非常方便的功能,可以快速編寫一些測試demo,是開發元件必備的環境。下面是安裝使用步驟

// 全域性安裝vue-cli3
npm install -g @vue/cli
vue -V // 檢視版本是否為3.x

// 安裝擴充套件,此後可以快速啟動單個vue檔案
npm install -g @vue/cli-service-global

// 快速啟動demo檔案
vue serve demo.vue
複製程式碼

如果需要scss,則還需要在目錄下安裝sass-loader等。

下面是使用vue-cli3可能會遇見的幾個問題,更多使用教程可以參考:一份超級詳細的Vue-cli3.0使用教程[趕緊來試試!]

自定義入口檔案

如果需要(比如需要開發移動端的元件),可以在使用vue serve時自定義html入口檔案,在根目錄下編寫index.html,並確保頁面包含#app的dom即可。

引入公共混合檔案

通過style-resources-loader在每個檔案引入公共樣式混合等,參考自動化匯入

需要訪問Vue全域性物件

在某些時候需要放問全域性Vue物件,如開發全域性指令、外掛時

import Vue from "vue"
import somePlugin from "../src/somePlugin"

Vue.use(somePlugin)
複製程式碼

上面這種寫法並不會生效,這是因為vue serve xxx.vue僅僅只能作為快速原型開發的方案,使用的Vue與 import引入的Vue不是同一個物件。一種解決辦法是手動指定vue serve的入口檔案

// index.js
import Vue from "../node_modules/vue/dist/vue.min"
import placeholder from "../src/placeholder/placeholder"

Vue.use(placeholder)

new Vue({
    el: "#app",
    template: ``,
    created(){},
})
複製程式碼

Vue的元件系統

Vue元件的API主要包含三部分:prop、event、slot

  • props 表示元件接收的引數,最好用物件的寫法,這樣可以針對每個屬性設定型別、預設值或自定義校驗屬性的值,此外還可以通過type、validator等方式對輸入進行驗證
  • slot可以給元件動態插入一些內容或元件,是實現高階元件的重要途徑;當需要多個插槽時,可以使用具名slot
  • event是子元件向父元件傳遞訊息的重要途徑

單向資料流

參考:單向資料流-官方文件

父級 prop 的更新會向下流動到子元件中,但是反過來則不行

單向資料流是Vue元件一個非常明顯的特徵,不應該在子元件中直接修改props的值

  • 如果傳遞的prop僅僅用作展示,不涉及修改,則在模板中直接使用即可
  • 如果需要對prop的值進行轉化然後展示,則應該使用computed計算屬性
  • 如果prop的值用作初始化,應該定義一個子元件的data屬性並將prop作為其初始值

從原始碼/src/core/vdom/create-component.js/src/core/vdom/helpers/extract-props.js裡可以看見,在處理props的取值時,首先從

function extractPropsFromVNodeData(){
  const res = {}
  const { attrs, props } = data
  // 執行淺拷貝
  checkProp(res, props, key, altKey, true) || checkProp(res, attrs, key, altKey, false)

  return res
}

複製程式碼

在子元件修改props,卻不會修改父元件,這是因為extractPropsFromVNodeData中是通過淺複製將attrs傳遞給props的。

淺複製意味著在子元件中對物件和陣列的props進行修改還是會影響父元件,這就違背了單向資料流的設計。因此需要避免這種情況出現。

元件之間的通訊

這裡可以參考:vue元件通訊全揭祕,寫的比較全面

  • 父子元件的關係可以總結為 prop 向下傳遞,事件event向上傳遞
  • 祖先元件和後代元件(跨多代)的資料傳遞,可以使用provide和inject來實現

此外,如果需要跨元件或者兄弟元件之間的通訊,可以通過eventBus或者vuex等方式來實現。

“繞開”單向資料流

考慮下面場景:父元件將資料通過prop形式傳遞給子元件,子元件進行相關操作並修改資料,需要修改父元件的prop值(一個典型的例子是:購物車的商品數量counter元件)。

根據元件單向資料流和和事件通訊機制,需要由子元件通過事件通知父元件,並在父元件中修改原始的prop資料,完成狀態的更新。在子元件中修改父元件的資料的場景在業務中也是比較常見的,那麼有什麼辦法可以“繞開”單向資料流的限制呢?

狀態提升

可以參考React的狀態提升,直接通過props將父元素的資料處理邏輯傳入子元件,子元件只做資料展示和事件掛載即可

<template>
    <div class="counter">
        <div class="counter_btn" @click="onMinus">-</div>
        <div class="counter_val">{{value}}</div>
        <div class="counter_btn" @click="onPlus">+</div>
    </div>
</template>

<script>
    export default {
        props: {
            value: {
                type: Number,
                default: 0
            },
            onMinus: Function,
            onPlus: Function
        },
    };
</script>
複製程式碼

然後在呼叫時傳入事件處理函式

<template>
    <div>
        <counter :value="counter2Val" :on-minus="minusVal" :on-plus="plusVal"></counter>
    </div>
</template>
<script>
    export default {
        data() {
            return {
                counter2Val: 0,
            }
        },
        methods: {
            minusVal(){
                this.counter2Val--
            },
            plusVal(){
                this.counter2Val++
            }
        }
    }
</script>
複製程式碼

很明顯,由於在每個父元件中都需要實現on-minuson-plus,因此狀態提升並沒有從根本上解決問題。

v-model語法糖

Vue內建了v-model指令,v-model 是一個語法糖,可以拆解為 props: value 和 events: input。就是說元件只要提供一個名為 value 的 prop,以及名為 input 的自定義事件,滿足這兩個條件,使用者就能在自定義元件上使用 v-model

<template>
  <div>
    <button @click="changeValue(-1)">-1</button>
    <span>{{currentVal}}</span>
    <button @click="changeValue(1)">+1</button>
  </div>
</template>

<script>
export default {
  props: {
    value: {
      type: Number // 定義value屬性
    }
  },
  data() {
    return {
      currentVal: this.value
    };
  },
  methods: {
    changeVal(val) {
      this.currentVal += parseInt(val);
      this.$emit("input", this.currentVal); // 定義input事件
    }
  }
};
</script>

複製程式碼

然後呼叫的時候只需要傳入v-model指令即可

<counter v-model="counerVal"/>
複製程式碼

使用v-model,可以很方便地在子元件中同步父元件的資料。在2.2之後的版本中,可以定製v-model指令的prop和event名稱,參考model配置項

export default {
    model: {
        prop: 'value',
        event: 'input'
    },
    // ...
 }
複製程式碼

獲得元件例項的引用

在開發元件中,獲取元件例項是一個非常有用的方法。元件可以通過$refs$parents$children等方式獲得vm例項引用

  • $refs在元件(或者dom上)增加ref屬性即可

  • $parents獲取子元件掛載的父元件節點

  • $children,獲取元件的所有子節點

這些介面返回的都是vnode,可以通過vnode.componentInstance獲得對應的元件例項,然後直接呼叫元件的方法或訪問資料。雖然這種方式多多少少有些違背元件的設計理念,增加了元件之間的耦合成本,但程式碼實現會更加簡潔。

表單驗證元件

通常情況下,表單驗證是表單提交前一個十分常見的應用場景。那麼,如何把表單驗證的功能封裝在元件內部呢?

下面是一個表單元件的示例,展示了通過獲得元件的引用來實現表單驗證功能。

首先定義元件的使用方式,

  • xm-form接收modelrule兩個prop
    • model表示表單繫結的資料物件,最後表單提交的就是這個物件
    • rule表示驗證規則策略,表單驗證可以使用async-validator外掛
  • xm-form-item接收的prop屬性,對應form元件的model和rule的某個key值,根據該key從model上取表單資料,從rule上取驗證規則

下面是使用示例程式碼

<template>
    <div class="page">
        <xm-form :model="form" :rule="rule" ref="baseForm">
            <xm-form-item label="姓名" prop="name">
                <input v-model="form.name"/>
            </xm-form-item>
            <xm-form-item label="郵箱" prop="email">
                <input v-model="form.email"/>
            </xm-form-item>
            <xm-form-item>
                <button @click="submit">提交</button>
            </xm-form-item>
        </xm-form>
    </div>
</template>

<script>
    import xmForm from "../src/form/form"
    import xmFormItem from "../src/form/form-item"

    export default {
        components: {
            xmForm,
            xmFormItem,
        },
        data() {
            return {
                form: {
                    name: "",
                    email: ""
                },
                rule: {
                    name: [
                        {required: true, message: '使用者名稱不能為空', trigger: 'blur'}
                    ],
                    email: [
                        {required: true, message: '郵箱不能為空', trigger: 'blur'},
                        {type: 'email', message: '郵箱格式不正確', trigger: 'blur'}
                    ],
                }
            }
        },
        methods: {
            submit() {
                // 呼叫form元件的validate方法
                this.$refs.baseForm.validate().then(res => {
                    console.log(res)
                }).catch(e => {
                    console.log(e)
                })
            }
        }
    }
</script>
複製程式碼

接下來讓我們實現form-item元件,其主要作用是放置表單元素,及展示錯誤資訊

<template>
    <label class="form-item">
        <div class="form-item_label">{{label}}</div>
        <div class="form-item_mn">
            <slot></slot>
        </div>
        <div class="form-item_error" v-if="errorMsg">{{errorMsg}}</div>
    </label>
</template>
<script>
    export default {
        name: "form-item",
        props: {
            label: String,
            prop: String
        },
        data() {
            return {
                errorMsg: ""
            }
        },
        methods: {
            showError(msg) {
                this.errorMsg = msg
            }
        }
    }
</script>
複製程式碼

然後讓我們來實現form元件

  • 通過calcFormItems獲取每個xm-form-item的引用,儲存在formItems中
  • 暴露validate介面,內部呼叫AsyncValidator,並根據結果遍歷formItems中每個表單元素的prop屬性,處理對應的error資訊
<template>
    <div class="form">
        <slot></slot>
    </div>
</template>

<script>
    import AsyncValidator from 'async-validator';

    export default {
        name: "xm-form",
        props: {
            model: {
                type: Object
            },
            rule: {
                type: Object,
                default: {}
            }
        },
        data() {
            return {
                formItems: []
            }
        },
        mounted() {
            this.calcFormItems()
        },
        updated() {
            this.calcFormItems()
        },
        methods: {
            calcFormItems() {
                // 獲取form-item的引用
                if (this.$slots.default) {
                    let children = this.$slots.default.filter(vnode => {
                        return vnode.tag &&
                            vnode.componentOptions && vnode.componentOptions.Ctor.options.name === 'form-item'
                    }).map(({componentInstance}) => componentInstance)

                    if (!(children.length === this.formItems.length && children.every((pane, index) => pane === this.formItems[index]))) {
                        this.formItems = children
                    }
                }
            },
            validate() {
                let validator = new AsyncValidator(this.rule);

                let isSuccess = true

                let findErrorByProp = (errors, prop) => {
                    return errors.find((error) => {
                        return error.field === prop
                    }) || ""
                }

                validator.validate(this.model, (errors, fields) => {
                    this.formItems.forEach(formItem => {
                        let prop = formItem.prop
                        let error = findErrorByProp(errors || [], prop)
                        if (error) {
                            isSuccess = false
                        }

                        formItem.showError(error && error.message || "")
                    })
                });

                return Promise.resolve(isSuccess)
            }
        }
    }
</script>
複製程式碼

這樣我們就完成了一個通用的表單驗證元件。從這個例子中可以看出獲取元件引用,在元件開發中是一個非常有用的方法。

封裝API元件

一些元件如提示框、彈出框等,更適合單獨的API呼叫方式,如

import MessageBox from '@/components/MessageBox.vue'
MessageBox.toast('hello)
複製程式碼

如何實現制這種不需要手動嵌入模板裡面的元件呢?原來,除了在通過在模板中嵌入元件到children掛載元件,Vue還為元件提供了手動掛載的方法$mount

let component = new MessageBox().$mount()
document.getElementById('app').appendChild(component.$el)
複製程式碼

通過這種方式,我們就是可以封裝API形式呼叫元件,下面是一個alert訊息提示的介面封裝

訊息彈窗元件

一個訊息元件就是在頁面指定繪製展示提示訊息的元件,下面是簡單實現

<template>
    <div class="alert">
        <div class="alert-main" v-for="item in notices" :key="item.name">
            <div class="alert-content">{{ item.content }}</div>
        </div>
    </div>
</template>

<script>
    let seed = 0;

    function getUuid() {
        return 'alert_' + (seed++);
    }

    export default {
        data() {
            return {
                notices: []
            }
        },
        methods: {
            add(notice) {
                const name = getUuid();

                let _notice = Object.assign({
                    name: name
                }, notice);

                this.notices.push(_notice);

                // 定時移除,單位:秒
                const duration = notice.duration;
                setTimeout(() => {
                    this.remove(name);
                }, duration * 1000);
            },
            remove(name) {
                const notices = this.notices;

                for (let i = 0; i < notices.length; i++) {
                    if (notices[i].name === name) {
                        this.notices.splice(i, 1);
                        break;
                    }
                }
            }
        }
    }
</script>
複製程式碼

下面來實現訊息元件掛載到頁面的邏輯,並對外暴露展示訊息的介面

// alert.js
import Vue from 'vue';

// 具體的元件
import Alert from './alert.vue';
Alert.newInstance = properties => {
    const props = properties || {};
	// 例項化一個元件,然後掛載到body上
    const Instance = new Vue({
        data: props,
        render (h) {
            return h(Alert, {
                props: props
            });
        }
    });
    const component = Instance.$mount();
    document.body.appendChild(component.$el);
	// 通過閉包維護alert元件的引用
    const alert = Instance.$children[0];
    return {
        // Alert元件對外暴露的兩個方法
        add (noticeProps) {
            alert.add(noticeProps);
        },
        remove (name) {
            alert.remove(name);
        }
    }
};

// 提示單例
let messageInstance;
function getMessageInstance () {
    messageInstance = messageInstance || Alert.newInstance();
    return messageInstance;
}
function notice({ duration = 1.5, content = '' }) {
    // 等待介面呼叫的時候再例項化元件,避免進入頁面就直接掛載到body上
    let instance = getMessageInstance();
    instance.add({
        content: content,
        duration: duration
    });
}

// 對外暴露的方法
export default {
    info (options) {
        return notice(options);
    }
}
複製程式碼

然後就可以使用API的方式來呼叫彈窗元件了

import alert from './alert.js'
// 直接使用
alert.info({content: '訊息提示', duration: 2})
// 或者掛載到Vue原型上
Vue.prototype.$Alert = alert
// 然後在元件中使用
this.$Alert.info({content: '訊息提示', duration: 2})
複製程式碼

高階元件

高階元件可以看做是函數語言程式設計中的組合。可以把高階元件看做是一個函式,他接收一個元件作為引數,並返回一個功能增強的元件。

高階元件是一個接替Mixin實現抽象元件公共功能的方法,不會因為元件的使用而汙染DOM(新增並不想要的div標籤等)、可以包裹任意的單一子元素等等

在React中高階元件是比較常用的元件封裝形式,在Vue中如何實現高階元件呢?

在元件的render函式中,只需要返回一個vNode資料型別即可,如果在render函式中提前做一些處理,並返回this.$slots.default[0]對應的vnode,就可以實現高階元件。

內建的keep-alive

Vue內建了一個高階元件keep-alive,檢視原始碼可以發現其實現原理,就是通過維護一個cache,並在render函式中根據key返回快取的vnode,來實現元件的持久化。

throttle

節流是web開發中處理事件比較常見的需求。常見的場景有及時搜尋框避免頻繁觸發搜尋介面、表單按鈕防止在短暫時間誤重複提交等

首先來看看Throttle元件的使用方式,接收兩個props

  • time表示節流的時間間隔
  • events表示需要處理的事件名,多個事件用逗號分隔

在下面的例子中,通過Throttle元件來控制其內部button的點選事件,此時連續點選多次,觸發clickBtn的次數要比點選的次數小(節流函式通過一個定時器進行處理)。

 <template>
    <div>
        <Throttle :time="1000" events="click">
            <button @click="clickBtn">click {{count}}</button>
        </Throttle>
    </div>
</template>
複製程式碼

下面是具體實現,實現高階元件的主要功能是在render函式中對當前插槽中的vnode進行處理

const throttle = function (fn, wait = 50, ctx) {
    let timer
    let lastCall = 0
    return function (...params) {
        const now = new Date().getTime()
        if (now - lastCall < wait) return
        lastCall = now
        fn.apply(ctx, params)
    }
}

export default {
    name: 'throttle',
    abstract: true,
    props: {
        time: Number,
        events: String,
    },
    created() {
        this.eventKeys = this.events.split(',')
        this.originMap = {}
        this.throttledMap = {}
    },
    // render函式直接返回slot的vnode,避免外層新增包裹元素
    render(h) {
        const vnode = this.$slots.default[0]
        this.eventKeys.forEach((key) => {
            const target = vnode.data.on[key]
            if (target === this.originMap[key] && this.throttledMap[key]) {
                vnode.data.on[key] = this.throttledMap[key]
            } else if (target) {
                // 將原本的事件處理函式替換成throttle節流後的處理函式
                this.originMap[key] = target
                this.throttledMap[key] = throttle(target, this.time, vnode)
                vnode.data.on[key] = this.throttledMap[key]
            }
        })
        return vnode
    },
}
複製程式碼

我們還可以進一步封裝,通過debounce函式來實現Debounce元件,可見高階元件的作用,就是為了增強某個元件而存在的。關於高階元件的其他應用,可以參考HOC(高階元件)在vue中的應用

小結

本文整理了幾種實現Vue元件的技巧

  • 以counter計數器元件為例,展示了通過v-model語法糖同步父子元件的方式
  • 以表單驗證元件為例,展示了通過獲取子元件的例項來封裝元件的方法
  • 以全域性彈窗元件為例,展示了手動mount掛載元件封裝API元件的方式
  • 以throttle節流元件為例,展示了在vue中一種實現高階元件的方式

在瞭解Vue的API之後,理解上面的概念都比較輕鬆,封裝元件,除了對於API的熟練度之外,更多地是考察JavaScript基礎。Vue入門十分輕鬆,但是要寫好優雅的Vue程式碼,也是一份不小的學問。

相關文章