V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
推荐关注
Meteor
JSLint - a JavaScript code quality tool
jsFiddle
D3.js
WebStorm
推荐书目
JavaScript 权威指南第 5 版
Closure: The Definitive Guide
Aresn
V2EX  ›  JavaScript

Vue 中你不知道但却很实用的黑科技

  •  
  •   Aresn · 2016-12-05 09:50:51 +08:00 · 8186 次点击
    这是一个创建于 2915 天前的主题,其中的信息可能已经有所发展或是发生改变。

    最近数月一直投身于 iView 的开源工作中,完成了大大小小 30 多个 UI 组件,在 Vue 组件化开发中积累了不少经验。其中也有很多带有技巧性和黑科技的组件,这些特性有的是 Vue 文档中提到但却容易被忽略的,有的更是没有写在文档里,今天就说说 Vue 组件的高级玩法。

    写在前面

    本文所讲内容大多在 iView 项目中使用,大家可以前往关注,并结合源代码来研究其中的奥妙。项目地址: https://github.com/iview/iview

    目录

    • 递归组件
    • 自定义组件使用 v-model
    • 使用$compile()在指定上下文中手动编译组件
    • 内联模板inline-template
    • 隐式创建 Vue 实例

    递归组件

    递归组件在文档中有介绍,只要给组件指定一个 name字段,就可以在该组件递归地调用自己,例如:

    var iview = Vue.extend({
      name: 'iview',
      template:
        '<div>' +
          // 递归地调用它自己
          '<iview></iview>' +
        '</div>'
    })
    

    这种用法在业务中并不常见,在 iView 的级联选择组件中使用了该特性 (https://github.com/iview/iview/tree/master/src/components/cascader) 效果如下图所示: 图中每一列是一个组件(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 中有使用: https://github.com/iview/iview/tree/master/src/components/table/cell.vue 由于表格的列配置是通过一个 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 来管理。相关代码地址: https://github.com/iview/iview/tree/master/src/components/base/notification

    下面我们来看一下具体实现: 上图是最终效果图,这部分 .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 😝:

    https://github.com/iview/iview

    20 条回复    2017-02-18 15:41:53 +08:00
    maelon
        1
    maelon  
       2016-12-05 10:11:33 +08:00
    扩展 Vue 我们一般是通过 plugin 的方式:
    'use strict';

    const i18n = {
    install(Vue, options) {
    Vue.prototype.$lang = (options && options['lang']) || 'cn';
    Vue.prototype.$i18n = k => {
    if(typeof k === 'string') {
    const lang = (options && options['lang']) || 'cn';
    if(window.language_desc && typeof window.language_desc === 'object') {
    if(window.language_desc[k] && window.language_desc[k][lang] !== undefined) {
    return window.language_desc[k][lang];
    } else {
    return k;
    }
    }
    return k;
    }
    throw new Error('this.$i18n() receive a string parameter as key!');
    };
    }
    };

    export default i18n;

    便于代码管理
    soli
        2
    soli  
       2016-12-05 10:45:47 +08:00
    大赞!

    请问,思维导图是用啥工具画的哈?
    shyling
        3
    shyling  
       2016-12-05 10:59:14 +08:00
    噗,命了个名后高大上了好多
    wujunze
        4
    wujunze  
       2016-12-05 11:07:56 +08:00
    感谢分享 赞一个
    mactaew
        5
    mactaew  
       2016-12-05 11:09:29 +08:00
    很精致, Stared
    zhenjiachen
        6
    zhenjiachen  
       2016-12-05 11:09:46 +08:00
    star
    kiros
        7
    kiros  
       2016-12-05 11:15:16 +08:00
    同问思维导图工具。。。
    kiros
        8
    kiros  
       2016-12-05 11:20:12 +08:00
    @kiros @Aresn 同问思维导图工具。。。
    ss098
        9
    ss098  
       2016-12-05 11:26:39 +08:00
    非常酷。

    网站首页感觉好炫啊。
    kikyous
        10
    kikyous  
       2016-12-05 11:28:36 +08:00
    能不能把 Select 选择器分离出来,现在找到的几个都不太好用
    Aresn
        11
    Aresn  
    OP
       2016-12-05 11:32:11 +08:00   ❤️ 2
    @kiros @soli 思维导图 MindNode
    soli
        12
    soli  
       2016-12-05 11:34:26 +08:00
    @Aresn 多谢。

    希望尽快支持 Vue 2.0 。 加油!
    Aresn
        13
    Aresn  
    OP
       2016-12-05 11:36:02 +08:00
    @kikyous 暂时依赖的组件比较多,不便于独立。你可以研究下源码自己实现也行
    iRiven
        14
    iRiven  
       2016-12-05 13:02:38 +08:00
    虽然看不懂,但是感觉给个星就对了,好东西
    peneazy
        15
    peneazy  
       2016-12-05 13:28:34 +08:00
    mark 一下
    ragnaroks
        16
    ragnaroks  
       2016-12-05 14:44:15 +08:00
    先+1s,
    然后 https://www.iviewui.com/components/modal ,在空白处或动作按钮上多次点击会多次触发事件
    HustLiu
        17
    HustLiu  
       2016-12-05 15:04:17 +08:00
    其实有些并不是黑科技,大家不知道也完全是因为没有仔细阅读官方文档。。比如 v-model 的利用,文档里写的很明白,它只是 <tag :value="prop" @input="prop = arguments[0]">的语法糖了。。可惜大部分人(我身边就是有一大堆。。)根本不仔细看文档。。得亏了有 po 主这样的有心人总结罗列了。。
    markyun
        18
    markyun  
       2016-12-05 16:10:56 +08:00
    很赞,已经 fork 了 iview 源码在细看
    teledius
        19
    teledius  
       2016-12-05 16:34:07 +08:00
    大多数人看到还是基于 1.0 的组件就直接放弃了,迁移到 2.0 也不是什么大工程,希望尽快支持 2.0 吧 。 加油
    bombless
        20
    bombless  
       2017-02-18 15:41:53 +08:00
    为啥 template 要直接给 html 字符串呢……感觉好蛋疼
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   5023 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 34ms · UTC 03:58 · PVG 11:58 · LAX 19:58 · JFK 22:58
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.