Vue中你不知道但卻很實用的黑科技

Aresn發表於2016-12-05

本文純技術乾貨,首發於 掘金,轉載請註明出處和作者。
最近數月一直投身於 iView 的開源工作中,完成了大大小小 30 多個 UI 元件,在 Vue 元件化開發中積累了不少經驗。其中也有很多帶有技巧性和黑科技的元件,這些特性有的是 Vue 文件中提到但卻容易被忽略的,有的更是沒有寫在文件裡,今天就說說 Vue 元件的高階玩法。

寫在前面

本文所講內容大多在 iView 專案中使用,大家可以前往關注,並結合原始碼來研究其中的奧妙。專案地址:
github.com/iview/iview

目錄

  • 遞迴元件
  • 自定義元件使用 v-model
  • 使用$compile()在指定上下文中手動編譯元件
  • 內聯模板inline-template
  • 隱式建立 Vue 例項

遞迴元件

遞迴元件在文件中有介紹,只要給元件指定一個 name欄位,就可以在該元件遞迴地呼叫自己,例如:

var iview = Vue.extend({
  name: 'iview',
  template:
    '<div>' +
      // 遞迴地呼叫它自己
      '<iview></iview>' +
    '</div>'
})複製程式碼

這種用法在業務中並不常見,在 iView 的級聯選擇元件中使用了該特性
github.com/iview/iview…
效果如下圖所示:

Vue中你不知道但卻很實用的黑科技

圖中每一列是一個元件(caspanel.vue),一開始想到用 v-for來渲染列表,但後面發現擴充套件性極低,而且隨著功能的豐富,實現起來很困難,處理的邏輯很多,於是改寫成了遞迴元件:

<ul v-if="data && data.length" :class="[prefixCls + '-menu']">
    <Casitem
        v-for="item in data"
        :prefix-cls="prefixCls"
        :data.sync="item"
        :tmp-item="tmpItem"
        @click.stop="handleClickItem(item)"
        @mouseenter.stop="handleHoverItem(item)"></Casitem>
</ul><Caspanel v-if="sublist && sublist.length" :prefix-cls="prefixCls" :data.sync="sublist" :disabled="disabled" :trigger="trigger" :change-on-select="changeOnSelect"></Caspanel>複製程式碼

props 比較多,可以忽略,但其中關鍵的兩個是datasublist,即當前列資料和子集的資料,因為預先不知道有多少下級,所以只需傳遞下級資料給元件本身,如果為空時,遞迴就結束了,Vue 這樣設計的確很精妙。
注:該方法在 Vue 1.x 和 2.x 中都支援。

自定義元件使用 v-model

我們知道,v-model是在表單類元素上進行雙向繫結時使用的,比如:

<template>
    <input type="text" v-model="data">
    {{ data }}
</template>
<script>
    export default {
        data () {
            return {
                data: ''
            }
        }
    }
</script>複製程式碼

這時data就是雙向繫結的,輸入的內容會實時顯示在頁面上。在 Vue 1.x 中,自定義元件可以使用 props 的.sync雙向繫結,比如:

<my-component :data.sync="data"></my-component>複製程式碼

在 Vue 2.x 中,可以直接在自定義元件上使用 v-model了,比如:

<my-component v-model="data"></my-component>複製程式碼

在元件my-component中,通過this.$emit('input')就可以改變data的值了。
雖然 Vue 1.x 中無法這樣使用,但是如果你的元件的模板外層是 inputselecttextarea等支援繫結 v-model 特性的元素,也是可以使用的,比如 my-component 的程式碼是:

<template>
    <input type="text">
</template>複製程式碼

那也可以使用上面2.x的寫法。

使用$compile()在指定上下文中手動編譯元件

注:該方法是在 Vue 1.x 中的使用介紹,官方文件並沒有給出該方法的任何說明,不可過多依賴此方法。
使用$compile()方法,可以在任何一個指定的上下文(Vue例項)上手動編譯元件,該方法在 iView 新發布的表格元件 Table 中有使用:
github.com/iview/iview…
由於表格的列配置是通過一個 Object 傳入 props 的,因此不能像 slot 那樣自動編譯帶有 Vue 程式碼的部分,因為傳入的都是字串,比如:

{
    render (row) {
        return `<i-button>${row.name}</i-button>`
    }
}複製程式碼

render函式最終返回一個字串,裡面含有一個自定義元件 i-button,如果直接用{{{ }}}顯示,i-button 是不會被編譯的,那為了實現在單元格內支援渲染自定義元件,就用到了$compile()方法。
比如我們在元件的父級編譯:

// 程式碼片段
const template = this.render(this.row);    // 通過上面的render函式得到字串
const div = document.createElement('div');
div.innerHTML = template;
this.$parent.$compile(div);    // 在父級上下文編譯元件
this.$el.appendChild(cell);    // 將編譯後的html插入當前元件複製程式碼

這樣一來, i-button就被編譯了。
在某些時候使用$compile()確實能帶來益處,不過也會遇到很多問題值得思考:

  • 這樣編譯容易把作用域搞混,所以要知道是在哪個Vue例項上編譯的;
  • 手動編譯後,也需要在合適的時候使用$destroy()手動銷燬;
  • 有時候容易重複編譯,所以要記得儲存當前編譯例項的id,這裡可以通過 Vue 元件的_uid來唯一標識(每個Vue例項都會有一個遞增的id,可以通過this._uid獲取)

另外,Vue 1.x 文件也有提到另一個$mount()方法,可以實現類似的效果,在 Vue 2.x 文件中,有 Vue.compile()方法,用於在render函式中編譯模板字串,讀者可以結合來看。

內聯模板inline-template

內聯模板並不是什麼新鮮東西,文件中也有說明,只是平時幾乎用不到,所以也容易忽略。簡短解說,就是把元件的 slot 當做這個元件的模板來使用,這樣更為靈活:

<!-- 父元件: -->
<my-component inline-template>
    {{ data }}
</my-component>

<!-- 子元件 -->
<script>
    export default {
        data () {
            return {
                data: ''
            }
        }
    }
</script>複製程式碼

因為使用了 inline-template 內聯模板,所以子元件不需要<template>來宣告模板,這時它的模板直接是從 slot 來的{{ data }},而這個 data 所在的上下文,是子元件的,並不是父元件的,所以,在使用內聯模板時,最容易產生的誤區就是混淆作用域。

隱式建立 Vue 例項

在 webpack 中,我們都是用 .vue 單檔案的模式來開發,每個檔案即一個元件,在需要的地方通過 components: {}來使用元件。
比如我們需要一個提示框元件,可能會在父級中這樣寫:

<template>
    <Message>這是提示標題</Message>
</template>
<script>
    import Message from '../components/message.vue';
    export default {
        components: { Message }
    }
</script>複製程式碼

這樣寫沒有任何問題,但從使用角度想,我們其實並不期望這樣來用,反而原生的window.alert('這是提示標題')這樣使用起來更靈活,那這時很多人可能就用原生 JS 拼字串寫一個函式了,這也沒問題,不過如果你的提示框元件比較複雜,而且多處複用,這種方法還是不友好的,體現不到 Vue 的價值。
iView 在開發全域性提示元件(Message)、通知提醒元件(Notice)、對話方塊元件(Modal)時,內部都是使用 Vue 來渲染,但卻是 JS 來隱式地建立這些例項,這樣我們就可以像Message.info('標題')這樣使用,但其內部還是通過 Vue 來管理。相關程式碼地址:
github.com/iview/iview…

下面我們來看一下具體實現:

Vue中你不知道但卻很實用的黑科技

上圖是最終效果圖,這部分 .vue 程式碼比較簡單,相信大家都能寫出這樣一個元件來,所以直接說建立例項的部分,先看下核心程式碼:

import Notification from './notification.vue';
import Vue from 'vue';
import { camelcaseToHyphen } from '../../../utils/assist';

Notification.newInstance = properties => {
    const _props = properties || {};

    let props = '';
    Object.keys(_props).forEach(prop => {
        props += ' :' + camelcaseToHyphen(prop) + '=' + prop;
    });

    const div = document.createElement('div');
    div.innerHTML = `<notification${props}></notification>`;
    document.body.appendChild(div);

    const notification = new Vue({
        el: div,
        data: _props,
        components: { Notification }
    }).$children[0];

    return {
        notice (noticeProps) {
            notification.add(noticeProps);
        },
        remove (key) {
            notification.close(key);
        },
        component: notification,
        destroy () {
            document.body.removeChild(div);
        }
    }
};

export default Notification;複製程式碼

與上文介紹的$compile()不同的是,這種方法是在全域性(body)直接使用 new Vue建立一個 Vue 例項,我們只需要在入口處對外暴露幾個 API 即可:

import Notification from '../base/notification';

const prefixCls = 'ivu-message';
const iconPrefixCls = 'ivu-icon';
const prefixKey = 'ivu_message_key_';

let defaultDuration = 1.5;
let top;
let messageInstance;
let key = 1;

const iconTypes = {
    'info': 'information-circled',
    'success': 'checkmark-circled',
    'warning': 'android-alert',
    'error': 'close-circled',
    'loading': 'load-c'
};

function getMessageInstance () {
    messageInstance = messageInstance || Notification.newInstance({
        prefixCls: prefixCls,
        style: {
            top: `${top}px`
        }
    });

    return messageInstance;
}

function notice (content, duration = defaultDuration, type, onClose) {
    if (!onClose) {
        onClose = function () {

        }
    }
    const iconType = iconTypes[type];

    // if loading
    const loadCls = type === 'loading' ? ' ivu-load-loop' : '';

    let instance = getMessageInstance();

    instance.notice({
        key: `${prefixKey}${key}`,
        duration: duration,
        style: {},
        transitionName: 'move-up',
        content: `
            <div class="${prefixCls}-custom-content ${prefixCls}-${type}">
                <i class="${iconPrefixCls} ${iconPrefixCls}-${iconType}${loadCls}"></i>
                <span>${content}</span>
            </div>
        `,
        onClose: onClose
    });

    // 用於手動消除
    return (function () {
        let target = key++;

        return function () {
            instance.remove(`${prefixKey}${target}`);
        }
    })();
}

export default {
    info (content, duration, onClose) {
        return notice(content, duration, 'info', onClose);
    },
    success (content, duration, onClose) {
        return notice(content, duration, 'success', onClose);
    },
    warning (content, duration, onClose) {
        return notice(content, duration, 'warning', onClose);
    },
    error (content, duration, onClose) {
        return notice(content, duration, 'error', onClose);
    },
    loading (content, duration, onClose) {
        return notice(content, duration, 'loading', onClose);
    },
    config (options) {
        if (options.top) {
            top = options.top;
        }
        if (options.duration) {
            defaultDuration = options.duration;
        }
    },
    destroy () {
        let instance = getMessageInstance();
        messageInstance = null;
        instance.destroy();
    }
}複製程式碼

到這裡元件已經可以通過Message.info()直接呼叫了,不過我們還可以在 Vue 上進行擴充套件:
Vue.prototype.$Message = Message;
這樣我們可以直接用this.$Message.info()來呼叫,就不用 import Message 了。

後記

Vue 元件開發中有很多有意思的技巧,用好了會減少很多不必要的邏輯,用不好反而還弄巧成拙。在開發一個較複雜的元件時,一定要先對技術方案進行調研和設計,然後再編碼。
iView 還有很多開發技巧和有意思的程式碼,後面有時間我們再繼續探討吧,最近釋出的幾個版本都有較大的更新,希望大家可以關注和推廣 iView ?:

github.com/iview/iview

相關文章