张欢

在1小时内创建自定义Vue.js插件

原文链接: snipcart.com
We're very excited to sponsor **VueConf TO 2018** https://vuetoronto.com

Come hang out and learn some Vue.js with world-class developers (Nov. 15-16).


很着急? 跳到 教程 或者 实例.

我们 ❤️ Vue.js已经不是什么秘密了。

以至于它是我们产品重构的一个关键部分。

在过去的几个月里, 我们学到了 很多 关于vue的知识。 从创建 SEO友好的SPA 到制作 杀手级博客 或玩 过渡 & 动画,我们已经彻底的试验了这个框架。

但是对于缺失的功能:

框架大多数支持者不得不使用Vue.js插件。

为了我的乐趣 (也希望是你的)我创建了一个自定义插件,向你展示如何在一步一步的教程中完成它。

我也将用这篇文章来回答一些重要的vue插件问题:

  • 什么是插件?

  • 他们对什么有用?

  • 什么是流行的Vue.js插件?

魔术时间到了!

Vue.js插件的简短故事

vue-js-plugin

插件到底是什么?

插件不是vue.js特有的东西,你通常会在很多软件中找到它们。根据定义,它们表明提供了一个接口来支持可扩展性。

换句更简单的话来说,他们是向应用程序添加全局功能的一种方法.

在vue.js中,一个插件应该暴露出带有2个参数的安装方法:

  1. 全局Vue对象。

  2. 包含用户定义选项的对象。

好消息是他们不是那么令人生畏。vue.js的基本知识就可以让你马上开始摆弄插件。

为什么要使用他们?

因为他们简单又强大。

如果你想要提高你的vue技能,不使用插件就是一个很大的失误。

根据官方 Vue.js 文档,以下是不同类型的Vue插件:

  1. 添加全局方法或属性。

  2. 添加一个或多个全局资产 (指令、过滤器、转换等。)

  3. 通过全局mixin添加组件选项。

  4. 通过将Vue示例方法附加到Vue.prototype来添加Vue实例方法。

  5. 创建一个库,在注入上述API的同时提供自己的API。

如果你看到这些类别中的任何一个是你需要的,你将很高兴的知道,Vue.js社区中已经提出了许多用于使用的解决方案。

流行的Vue.js插件

在开始一个新的Vue项目之前。我认为你知道以下插件的存在是很重要的:

Vue-router

如果你正在构建单页面应用程序,那么毫无疑问你需要使用Vue-router。作为Vue.js的官方路由,它与其核心深度集成,以完成映射组件和嵌套路由等任务。

Vuex

作为应用程序中所有组件的集中存储,如果你希望构建具有高维护性的大型应用程序,Vuex是一个明智的选择。

Vee-validate

当构建典型的业务应用程序时,如果不小心处理,表单验证很快就会变得难以管理。Vee-validate以优雅的方式处理这一切。它使用指令,并在构建时考虑了本地化。

我仅仅使用了这些插件,但是要知道 还有许多其他插件 等待着帮助Vue.js开发人员!

然而,你有时会偶然大仙一个未发现的用例,并陷入未知的领域。幸运的是,正如你将在下面文章中看到的,定制vue.js插件并不像你想象的那样具有挑战性。

创建一个自定义Vue.js插件

背景

Snipcart的母公司Spektrum,每一项设计工作都需要经过审批程序。客户可以对设计提出意见和建议,并最终予以批准。为了支持这种协作, 他们使用 InVision 平台。

评论系统是InVision的核心部分。它允许人们点击设计的任何部分,并为协作者留下范围注释。

然后评论会以徽章的形式出现在评论者点击的地方。

invision-commenting-system

让我们开发一个功能齐全的Vue.js插件!

它必须可插入到任何HTML元素上,并且在宿主程序中尽可能不具有侵入性。

预备知识

而已,让我们来实现它!

1. 准备代码库

感谢Vue CLI 3,现在可以比以往更轻松的初始化Vue.js代码库。安装CLI后,只需运行一下命令:

$ vue create vue-comments-overlay
# Answer the few questions
$ cd vue-comments-overlay
$ npm run serve


你将运行经典的Vue.js"Hello Word"应用程序。这将使你的测试应用。

2. 开发Vue.js插件

由于会有一些组件,所以最好将它们全部放在一个文件夹中。

$ mkdir src/plugins
$ mkdir src/plugins/CommentsOverlay
$ cd src/plugins/CommentsOverlay


2.1 编写基础

Vue.js 插件 基本上是具有安装功能的对象。 当使用者应用程序中包含带有Vue.use()的插件时,它就会被执行。

install函数接收全局Vue对象以及options对象最为参数。有了这个全局对象,扩展Vue的可能性实际上是无限的:扩充Vue的原型,添加自定义指令,甚至在插件中启动一个新的Vue实例(阻止警告框)。

为什么我们不从创建插件的框架开始呢?

// src/plugins/CommentsOverlay/index.js
// 
export default {
    install(vue, opts){   
        console.log('Installing the CommentsOverlay plugin!')
        // Fun will happen here
    }
}


现在,我们将它插入在你的测试应用中。

// src/main.js

import Vue from 'vue'
import App from './App.vue'
import CommentsOverlay from './plugins/CommentsOverlay'

Vue.use(CommentsOverlay)

Vue.config.productionTip = false

new Vue({ render: createElement => createElement(App)}).$mount('#app')


2.2 支持的选项

插件可以使用选项进行配置,选项是install函数的第二个参数。让我们创建表示插件基本行为的默认选项,即在没有指定自定义选项时如何操作。

// src/plugins/CommentsOverlay/index.js

const optionsDefaults = {
    // Retrieves the current logged in user that is posting a comment
    commenterSelector() {
        return {
            id: null,
            fullName: 'Anonymous',
            initials: '--',
            email: null
        }
    },
    data: {
        // Hash object of all elements that can be commented on
        targets: {},
        onCreate(created) {
            this.targets[created.targetId].comments.push(created)
        },
        onEdit(editted) {
            // This is obviously not necessary
            // It's there to illustrate what could be done in the callback of a remote call
            let comments = this.targets[editted.targetId].comments
            comments.splice(comments.indexOf(editted), 1, editted);
        },
        onRemove(removed) {
            let comments = this.targets[removed.targetId].comments
            comments.splice(comments.indexOf(removed), 1);
        }
    }
}


然后,合并默认值和传递给install函数的选项。

// src/plugins/CommentsOverlay/index.js

export default {
    install(vue, opts){

        // Merge options argument into options defaults
        const options = { ...optionsDefaults, ...opts }

        ...
    }
}


2.3 评论层的vue实例

使用这个插件要避免的一件事是,它的DOM和样式会干扰它所安装的应用程序。为了最大限度地减少这种情况发生的可能性,一种方法是使插件在主应用程序组件树之外的另一个根Vue实例中生效。

给install函数里添加以下内容:

// src/plugins/CommentsOverlay/index.js

export default {
    install(vue, opts){

        ...

    // Create plugin's root Vue instance
        const root = new Vue({
            data: { targets: options.data.targets },
            render: createElement => createElement(CommentsRootContainer)
        })

        // Mount root Vue instance on new div element added to body
        root.$mount(document.body.appendChild(document.createElement('div')))

        // Register data mutation handlers on root instance
        root.$on('create', options.data.onCreate)
        root.$on('edit', options.data.onEdit)
        root.$on('remove', options.data.onRemove)

        // Make the root instance available in all components
        vue.prototype.$commentsOverlay = root

        ...

    }
}


在上面代码片段中重要的点:

  1. 该应用程序存在于body最末尾的新div中。

  2. options对象中定义的时间处理程序被挂在到根实例上的匹配事件。这将在本教程结束时变的有意义。

  3. 添加到Vue原型的$commentsOverlay属性将根实例暴露给应用程序中的所有Vue组件。

2.4 自定义Vue.js指令

最后,你需要一种方法让使用应用程序者告诉插件哪个元素将启用comments。 这是一个自定义Vue.js指令的例子Vue.js directive。由于插件可以访问全局Vue对象,因此可以定义新指令。

将被命名为comments-enabled,它是这样的:

// src/plugins/CommentsOverlay/index.js

export default {
    install(vue, opts){

        ...

        // Register custom directive tha enables commenting on any element
        vue.directive('comments-enabled', {
            bind(el, binding) {

                // Add this target entry in root instance's data
                root.$set(
                    root.targets,
                    binding.value,
                    {
                        id: binding.value,
                        comments: [],
                        getRect: () => el.getBoundingClientRect(),
                    });

                el.addEventListener('click', (evt) => {
                    root.$emit(commentTargetClicked__${binding.value}, {
                        id: uuid(),
                        commenter: options.commenterSelector(),
                        clientX: evt.clientX,
                        clientY: evt.clientY
                    })
                })
            }
        })
    }
}


该指令做了两件事:

  1. 它将目标添加到根实例的数据中。为它定义键binding.value。它使使用者能够为目标元素指定自己的id,如下所示:.

  2. 它在目标元素上注册了一个click时间处理程序,该处理程序在这个特定目标的根实例上触发一个事件。我们稍后再讨论如何处理它。

install函数现在完成了!

2.5 CommentsRootContainer 组件

CommentsRootContainer 是UI插件的根组件。我们来看看。

// src/plugins/CommentsOverlay/CommentsRootContainer.vue

<template>
  <div>
    <comments-overlay
        v-for="target in targets"
        :target="target"
        :key="target.id">
    </comments-overlay>
  </div>

</template>

<script>
import CommentsOverlay from "./CommentsOverlay";

export default {
  components: { CommentsOverlay },
  computed: {
    targets() {
      return this.$root.targets;
    }
  }
};
</script>


注意,target计算属性是如何从根组件的数据派生出来的。

现在,overlay组件是见证奇迹的时刻。让我们开始吧!

2.6 CommentsOverlay 组件

// src/plugins/CommentsOverlay/CommentsRootContainer.vue

<template>
  <div class="comments-overlay">

    <div class="comments-overlay__container" v-for="comment in target.comments" :key="comment.id" :style="getCommentPostition(comment)">
      <button class="comments-overlay__indicator" v-if="editting != comment" @click="onIndicatorClick(comment)">
        {{ comment.commenter.initials }}
      </button>
      <div v-else class="comments-overlay__form">
        <p>{{ getCommentMetaString(comment) }}</p>
        <textarea ref="text" v-model="text" />        
        <button @click="edit" :disabled="!text">Save</button>
        <button @click="cancel">Cancel</button>
        <button @click="remove">Remove</button>
      </div>
    </div>

    <div class="comments-overlay__form" v-if="this.creating" :style="getCommentPostition(this.creating)">
      <textarea ref="text" v-model="text" />
      <button @click="create" :disabled="!text">Save</button>
      <button @click="cancel">Cancel</button>
    </div>

  </div>
</template>

<script>
export default {
  props: ['target'],

  data() {
    return {
      text: null,
      editting: null,
      creating: null
    };
  },

  methods: {
    onTargetClick(payload) {
      this._resetState();
      const rect = this.target.getRect();

      this.creating = {
        id: payload.id,
        targetId: this.target.id,
        commenter: payload.commenter,
        ratioX: (payload.clientX - rect.left) / rect.width,
        ratioY: (payload.clientY - rect.top) / rect.height
      };
    },
    onIndicatorClick(comment) {
      this._resetState();
      this.text = comment.text;
      this.editting = comment;
    },
    getCommentPostition(comment) {
      const rect = this.target.getRect();
      const x = comment.ratioX * rect.width + rect.left;
      const y = comment.ratioY * rect.height + rect.top;
      return { left: ${x}px, top: ${y}px };
    },
    getCommentMetaString(comment) {
      return ${
        comment.commenter.fullName
      } - ${comment.timestamp.getMonth()}/${comment.timestamp.getDate()}/${comment.timestamp.getFullYear()};
    },
    edit() {
      this.editting.text = this.text;
      this.editting.timestamp = new Date();
      this._emit("edit", this.editting);
      this._resetState();
    },
    create() {
      this.creating.text = this.text;
      this.creating.timestamp = new Date();
      this._emit("create", this.creating);
      this._resetState();
    },
    cancel() {
      this._resetState();
    },
    remove() {
      this._emit("remove", this.editting);
      this._resetState();
    },
    _emit(evt, data) {
      this.$root.$emit(evt, data);
    },
    _resetState() {
      this.text = null;
      this.editting = null;
      this.creating = null;
    }
  },

  mounted() {
    this.$root.$on(commentTargetClicked__${this.target.id}, this.onTargetClick
    );
  },

  beforeDestroy() {
    this.$root.$off(commentTargetClicked__${this.target.id}, this.onTargetClick
    );
  }
};
</script>


这里有几点需要注意:

  • 组件接收完整的target对象作为属性。这里是存储comments数组和定位信息的地方。

  • 我们之前看到的 commentTargetClicked 事件处理程序在mounted和beforeDestory钩子中进行管理。

  • 根实例被用作事件的总线。即使这种方法通常不被鼓励,但我认为在这种情况下它是合理的,因为这些组件不是公开暴露的,可以看作是一个整体单元。

Aaaand,我们都准备好了! 现在,经过一些样式 (我不会扩展我没有把握的CSS技能), 我们的插件已准备好接受用户对目标元素的评论了。

demo演示和GitHub仓库

vue-js-plugin-demo

这里看demo演示

这里看GitHub仓库

结论

比起以往任何时候都使用CLI 3总是欣喜与Vue.js一起使用。我迫不及待地想要通过我们即将推出的Snipcart开发来进一步扩展它的界限。我们一定会让你们跟上这些实验的进度!

我花了不到一天的时间来构建这个插件。为了简洁起见,我省略了重新调整大小。虽然我们存储的是ratioX和ratioY,这对于在组件渲染时计算定位非常合适,但在初始加载后调整页面大小会破坏注释布局。

这可以使用window.onresize或者不久以后的 ResizeObserver来修复。在写这篇文章的时候, ResizeObserver是Chrome专有的; 看看这里 是否还有。

你对我的插件和Vue.js开发有什么看法?你有什么实验想和我们分享吗?在下面的部分中发表评论!