Vue.js 通过举一反三建立企业级组件库

1,219 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第5天,点击查看活动详情

不同的单页面应用中的标签存在大幅度的重复,这个时候我们会很快想到使用组件,但用法各式各样,逻辑混乱复杂。这个时候会立即想到解耦?解耦完后如何在公司内部建立组件库供其他人使用?

  • 如何安装插件
  • 如何从开源插件的源码获得经验
  • 解耦的关键点是什么
  • 如何灵活控制复杂样式
  • 建立企业级内部组件库的详细步骤

如何安装插件

在 Vue 的插件的使用过程中,首先需要搞清楚几个概念,如下:

在这里插入图片描述

(图片来自:cn.vuejs.org

按照惯例,遇到问题,首先去官方的说明或者提供的文档中寻找最初的答案。上图中就对插件进行了进行了概括的介绍。

从这个说明中能够知道,插件通常为 Vue 提供全局功能。

在我们通常的工作中,如果引入通用的第三方插件,比如 elementUI、view-design、jQuery 等等,均可以在入口 JS 使用 Vue.use 来调用插件。

在谈到 Vue.use 的时候,通过了解关于 Vue.use 的基本信息,如下:

在这里插入图片描述

(图片来自:cn.vuejs.org

这里就提到了两点重要的信息:

  1. 如果插件是一个对象,必须提供 install 方法。
  2. 如果插件是一个函数,它会被作为 install 方法。

对于这两点说明,我们在后续的 Bootstrap、bootstrapVue 和 view-design 三个第三方插件的 alert 的说明中可以看到它们对于 install 方法的不同的使用过程。

对于 install 方法的简要说明如下:

在这里插入图片描述

(图片来自:cn.vuejs.org

如何从开源插件的源码获得经验

在文章《深入解读 iView,解耦令人头疼的高度耦合负责逻辑》中笔者重点分析了 view-design 中的 Tree 组件是如何一步步完成的。这里我们沿用这种从源码中汲取经验的方法。

开放成熟的源码,往往在算法、业务、编码规范等等不同的角度,有比较高的参考价值。

在本文中,我们不仅仅局限于 view-design,结合 Bootstrap 和 bootstrapVue 来从横纵不同的方向,通过对比深入理解插件的实现过程。

Bootstrap 的 alert

首先参考官方的说明,来对基本环境进行配置:

在这里插入图片描述

(图片来自:v4.bootcss.com

在这里插入图片描述

(图片来自:v4.bootcss.com

配置完成 babel-loader 的加载器,在模板中调用,可以通过调试,查看执行过程:

在这里插入图片描述

下面是 Bootstrap 插件对应 alert 模块的的实现步骤:

/**
 * --------------------------------------------------------------------------
 * Bootstrap (v4.5.0): alert.js
 * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
 * --------------------------------------------------------------------------
 */

import $ from 'jquery'
import Util from './util'

/**
 * ------------------------------------------------------------------------
 * Constants
 * ------------------------------------------------------------------------
 */

const NAME                = 'alert'
const VERSION             = '4.5.0'
const DATA_KEY            = 'bs.alert'
const EVENT_KEY           = `.${DATA_KEY}`
const DATA_API_KEY        = '.data-api'
const JQUERY_NO_CONFLICT  = $.fn[NAME]

const SELECTOR_DISMISS = '[data-dismiss="alert"]'

const EVENT_CLOSE          = `close${EVENT_KEY}`
const EVENT_CLOSED         = `closed${EVENT_KEY}`

/**
 * click.bs.alert.data-api
 */
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`



const CLASS_NAME_ALERT = 'alert'
const CLASS_NAME_FADE  = 'fade'
const CLASS_NAME_SHOW  = 'show'

/**
 * ------------------------------------------------------------------------
 * Class Definition
 * ------------------------------------------------------------------------
 */

class Alert {

/**
 * 构造函数的使用方法和用途:
 *    https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/constructor
 *  
 */

  constructor(element) {
    debugger;
    this._element = element
  }

  // Getters

  /**
   * https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Classes/static
   * get 语法将对象属性绑定到查询该属性时将被调用的函数。
   * https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Functions/get
   */
  static get VERSION() {
    debugger;
    return VERSION
  }

  // Public
  close(element) {
    debugger;
    let rootElement = this._element
    if (element) {
      rootElement = this._getRootElement(element)
    }

    const customEvent = this._triggerCloseEvent(rootElement)

    if (customEvent.isDefaultPrevented()) {
      return
    }

    this._removeElement(rootElement)
  }

  dispose() {
    debugger;
    $.removeData(this._element, DATA_KEY)
    this._element = null
  }

  // Private

  _getRootElement(element) {
    debugger;
    const selector = Util.getSelectorFromElement(element)
    let parent     = false

    if (selector) {
      parent = document.querySelector(selector)
    }

    if (!parent) {
      parent = $(element).closest(`.${CLASS_NAME_ALERT}`)[0]
    }

    return parent
  }

  _triggerCloseEvent(element) {
    debugger;
    const closeEvent = $.Event(EVENT_CLOSE)

    $(element).trigger(closeEvent)
    return closeEvent
  }

  _removeElement(element) {
    debugger;
    $(element).removeClass(CLASS_NAME_SHOW)

    if (!$(element).hasClass(CLASS_NAME_FADE)) {
      this._destroyElement(element)
      return
    }

    const transitionDuration = Util.getTransitionDurationFromElement(element)

    $(element)
      .one(Util.TRANSITION_END, (event) => this._destroyElement(element, event))
      .emulateTransitionEnd(transitionDuration)
  }

  _destroyElement(element) {
    debugger;
    $(element)
      .detach()
      .trigger(EVENT_CLOSED)
      .remove()
  }

  // Static

  static _jQueryInterface(config) {
    debugger;
    return this.each(function () {
      const $element = $(this)
      let data       = $element.data(DATA_KEY)

      if (!data) {
        data = new Alert(this)
        $element.data(DATA_KEY, data)
      }

      if (config === 'close') {
        data[config](this)
      }
    })
  }

  static _handleDismiss(alertInstance) {
    debugger;
    return function (event) {
      if (event) {
        event.preventDefault()
      }

      alertInstance.close(this)
    }
  }
}

/**
 * ------------------------------------------------------------------------
 * Data Api implementation
 * ------------------------------------------------------------------------
 */

/**
 * EVENT_CLICK_DATA_API=>click.bs.alert.data-api,事件
 * SELECTOR_DISMISS=>[data-dismiss="alert"],子选择器
 * 
 * on() 方法在被选元素及其子元素上添加一个或多个事件处理程序
 * 
 * 语法:$(selector).on(event,childSelector,data,function)
 * 
 * event:一个或多个用空格分隔的事件类型和可选的命名空间
 * childSelector:规定只能添加到指定的子元素上的事件处理程序
 * function:当事件发生时要运行的函数
 */

$(document).on(
  EVENT_CLICK_DATA_API,
  SELECTOR_DISMISS,
  Alert._handleDismiss(new Alert())
)

/**
 * ------------------------------------------------------------------------
 * jQuery
 * ------------------------------------------------------------------------
 */

$.fn[NAME]             = Alert._jQueryInterface
$.fn[NAME].Constructor = Alert
$.fn[NAME].noConflict  = () => {
  debugger;
  $.fn[NAME] = JQUERY_NO_CONFLICT
  return Alert._jQueryInterface
}

export default Alert

这种方案在我们工作中实际上是比较常见的,但是它也是相对比较简洁的一种方式,没有过于复杂的逻辑。

通过 $.fn[NAME] 的形式,也就是使用属性访问器,来对 jQuery 的原型进行扩展,然后再直接使用 $(selector).alert('...') 进行调用。

其余的地方也都比较容易理解,通过不同的方法,对于元素的处理和事件的处置,均分离开来,这种情况下,也就相当于我们在设计模式中常常提到的单一职责的原则。

在这种方式下,才能够使不同的方法,不同的对象满足不同的功能。在单一职责的粒度相对较细的情况下,也便于划分功能界限,易于维护和修改。

举例来说,我们工作中常常用的分页组件,就可以把首页、末页、上一页、下一页、跳转到指定页,不同的动作进行拆分。但是这些不同的事件中有一个相同的动作就是翻页的动作,也就是说不论使用哪一个事件,它都将触发翻页。那么翻页的逻辑就可以作为公共业务提取。

这样我们常常感到头疼的翻页功能,便能够轻易的解决,并且预留对外的翻页后发生的事件监听接口,在翻页的过程中实现自定义的事件操作即可。而这个监听的过程实际上就是我们一些面向对象的语言中用到的观察者模式,比如 click 事件。

bootstrapVue 的 alert

在这里插入图片描述

(图片来自:bootstrap-vue.org

对于 bootstrapVue 来说,它和 Bootstrap 的区别还是比较大的。它的组件的提取方式上就和 Bootstrap 有不小的差别。Bootstrap 的 Dom 的基本内容基本上向原有的 Bootstrap 一样,大部分 dom 元素是直接写在外部的。

可是对于 bootstrapVue 来说,它作为组件的内容给提取到组件内部了。

导出组件的方式决定引入组件的方法,那么我们来看导出组件的方式是什么样的:

在这里插入图片描述

然后,我们观察一下 bootstrapVue:

在这里插入图片描述

注意这里的插件工厂的实现过程,它就与笔者在文章开始提到的,如果插件时一个对象,就必须提供一个 install 方法进行了实践。

在这里插入图片描述

通过公共的工厂,实现对应组件的注册安装。

在这里插入图片描述

前面说的都是组件的导出和注册使用上的区别,然后再深入组件内部,我们可以发现,这又是一种比较特殊的方式,它 iView 中的 Tree 组件不同的是,虽然它没有对应的后缀名为.vue 的模板文件,但是它的业务和渲染的过程完成在了 alert.js 中,这不仅和 iview 有所区别,它和 Bootstrap 也有区别,如下:

在这里插入图片描述

在这里插入图片描述

包括 NAME、参数、方法等等的定义,以及渲染方法的使用。

view-design 中的 alert

在这里插入图片描述

对于 view-design,在上一篇文章中我们已经做了详细介绍了在 Tree 组件的整个生命周期过程中涉及到的关键问题。这里为了用 alert 做横向的对比,我们又提到了它。可以看出它能够折射出 Tree 组件的影子。

这里值得我们借鉴的是,它使用 template 模板,而不是 render 函数来渲染 Dom。这种方式对于开发者来说打包之前的工作控制器里会稍微便捷,直白一点。

另外,它对于前缀的定义和使用,起到了命名空间的作用,这种情况下对于全局样式不会造成干扰。能够有效的从其他的类似元素中区分开来,独立控制。如下图:

在这里插入图片描述

对于一些其他的全局变量的提取,可以参考 index.js 中的 Vue.prototype.$IVIEW

在这里插入图片描述

通过这种横向的对比,以及就 view-design 而言,通过 alert 与上一篇中的 Tree 组件的纵向的对比,我们大概能够看出来对于组件的提取的常用方法。

然后,在结合我们自己创建的内部组件库,可以通过逐渐的扩展建立企业内部的私有组件库。减少重复工作量,节约开发成本。

解耦的关键点是什么?

解耦的关键点在于对于业务模块的结构要足够清晰。我们可以针对自己已经完成过的业务模块进行回顾。比如在线购物平台中最常见的购物车,比如 CMS 中的图文记录,比如涉及到年份、温度时候的 range slider,比如我们所有平台都离不开的翻页,等等。

这些功能点,基本上我们多多少少都会接触过那么几个。那么我们已经实现的功能,当产品经理带着需求走过来的时候,我们是否能够直接复用,还是再摘抄一遍代码后,重新调试,重新测试呢?

现实可能是,我们需要去一遍一遍的复制粘贴自己的代码或者是别人的代码。那么,如何改变这个现状,不再被产品部门的需求牵着鼻子往前走呢?解耦。

通过业务解耦,把公共业务分割,提取公用。像用第三方插件一样,去通过解耦,建立属于自己,属于团队,属于公司内部的组件库。

依托于解耦的代码,来实现业务的解耦。这个时候,我们在熟练掌握公司业务的基础上,需要去“23 种设计模式,6 大设计原则”中寻找答案。

明确观察者模式、策略模式、工厂模式、状态模式,等着这些常用模式对于常用业务的处理过程。遵守设计原则,比如在前面提到的几个插件中,都能够看出来它们对于不同功能模块的分割,各自的业务之间几乎没有任何交叉。这就是我们常说的“单一职责”原则,谁的活谁自己干,互不打扰,但是又能够协同作战。

如何灵活控制复杂样式

这个问题,在 iView 中,也就是 view-design 的源码中可以得到经验,再次使用前面的一张图:

在这里插入图片描述

  • 通过使用 prefixCls 这种前缀,相当于为元素使用命名空间,来区分其他的元素,避免引起冲突
  • 在拼接的类中,通过 computed 计算属性或者是 watch 侦听属性,来实现样式的动态修改。
  • 对于使用添加前缀区分后的 class 属性,通过使用外部的独立 css 文件中对于样式进行动态控制。

应该尽量避免使用内联的样式,内联样式在维护起来的时候相对麻烦。

建立企业级内部组件库的详细步骤

在日常工作中,不管是前端也好,后端也好,亦或是移动端也好。基本上都离不开以下的几种内容:

  • 列表,它包括图文或者纯文本,图文中可能又包括左图右文、左文右图、上文下图等等方法。
  • 懒加载,涉及到列表的地方,往往我们会看到对应的懒加载的业务。
  • 分页,在我们几乎所有的涉及到内容,或者大于 20 条数据的结果集的时候,几乎都会涉及到分页的问题。
  • 选项卡。
  • 树形图。

等许许多多的内容,可能都是我们的常规工作中经常会遇到的一些实现方法和方式。那么当我们在不同的项目中一次又一次的拷贝相同的内容的时候,我们不妨反思一下,这么做是否有意义,我们的时间是否应该浪费在这个问题上,如何寻找对应的出路?

针对 Vue 有没有像 Java 中对于 jar 包使用 Maven 或者 Gradle 管理的类似工具。这种情况下,我们应该如何搭建一套属于公司内部的 npm 呢?

下面来看一下,Verdacciod 的使用方法。

认识 Verdacciod:

在这里插入图片描述

这个东西,怎么安装,怎么使用,这里涉及到了持续交付的问题。尽可能地从官方提供的信息中获取官方的权威消息。

在这里插入图片描述

在这里插入图片描述

Windows 安装

执行命令,全局安装 verdaccio:

npm install -g verdaccio

执行 verdaccio 得到如下结果:

在这里插入图片描述

文本内容如下(ME:注意删掉不必要的信息,和遮盖图中的敏感信息):

PS G:*******> verdaccio
 warn --- config file  - C:\Users******\AppData\Roaming\verdaccio\config.yaml
 warn --- Verdaccio started
 warn --- Plugin successfully loaded: verdaccio-htpasswd
 warn --- Plugin successfully loaded: verdaccio-audit
 warn --- http address - http://localhost:4873/ - verdaccio/4.6.2

查看 config.yaml 配置文件,内容如下:

#
# This is the default config file. It allows all users to do anything,
# so don't use it on production systems.
#
# Look here for more config file examples:
# https://github.com/verdaccio/verdaccio/tree/master/conf
#

# path to a directory with all packages
storage: ./storage
# path to a directory with plugins to include
plugins: ./plugins

web:
  title: Verdaccio
  # comment out to disable gravatar support 取消注释禁用 gravatar
  # gravatar: false
  # by default packages are ordercer ascendant (asc|desc)
  # sort_packages: asc
  # convert your UI to the dark side
  # darkMode: true

# translate your registry, api i18n not available yet
# i18n:
# list of the available translations https://github.com/verdaccio/ui/tree/master/i18n/translations
#   web: en-US

auth:
  htpasswd:
    file: ./htpasswd
    # Maximum amount of users allowed to register, defaults to "+inf".
    # You can set this to -1 to disable registration.
    # max_users: 1000

# a list of other known repositories we can talk to
uplinks:
  npmjs:
    url: https://registry.npmjs.org/

packages:
  '@*/*':
    # scoped packages
    access: $all
    publish: $authenticated
    unpublish: $authenticated
    proxy: npmjs

  '**':
    # allow all users (including non-authenticated users) to read and
    # publish all packages
    #
    # you can specify usernames/groupnames (depending on your auth plugin)
    # and three keywords: "$all", "$anonymous", "$authenticated"
    access: $all

    # allow all known users to publish/publish packages
    # (anyone can register by default, remember?)
    publish: $authenticated
    unpublish: $authenticated

    # if package is not available locally, proxy requests to 'npmjs' registry
    proxy: npmjs

# You can specify HTTP/1.1 server keep alive timeout in seconds for incoming connections.
# A value of 0 makes the http server behave similarly to Node.js versions prior to 8.0.0, which did not have a keep-alive timeout.
# WORKAROUND: Through given configuration you can workaround following issue https://github.com/verdaccio/verdaccio/issues/301. Set to 0 in case 60 is not enough.
server:
  keepAliveTimeout: 60

middlewares:
  audit:
    enabled: true

# log settings
logs:
  - { type: stdout, format: pretty, level: http }
  #- {type: file, path: verdaccio.log, level: info}
#experiments:
#  # support for npm token command
#  token: false

# This affect the web and api (not developed yet)
#i18n:
#web: en-US

使用 localhost:4783 在浏览器中访问,得到如下内容:

在这里插入图片描述

可以选择语种为你熟悉的或者习惯使用的语种:

在这里插入图片描述

修改后语种得到修改:

在这里插入图片描述

如果使用 VS Code 的情况下,可以新建一个终端窗口:

在这里插入图片描述

根据需要,进行选择切换。在新建的窗口中输入 上述步骤中 localhost:4873 对应的 verdaccio 首页中添加用户的提示:

在这里插入图片描述

拷贝执行,查看得到的执行结果:

在这里插入图片描述

根据提示,输入并执行,设置:

  • Username:changyandou
  • Password:changyandou(注:密码在终端中输入的时候不会显现出来)
  • Email:changyandou@126.com

最后打印输出内容:

Logged in as changyandou on http://localhost:4873

然后按照第二步骤提示的发布来操作:

在这里插入图片描述

npm publish --registry http://localhost:4873

得到如下的提示内容:

在这里插入图片描述

npm notice 
npm notice package: ***@1.0.0
npm notice === Tarball Contents === 
npm notice 242B  .babelrc
npm notice 156B  .editorconfig
npm notice 0     static/.gitkeep
npm notice 6.4kB src/css/iconfont.css       
npm notice 1.1kB src/css/reset.css
npm notice 277B  index.html
npm notice 256B  .postcssrc.js
npm notice 1.2kB build/build.js
npm notice 1.3kB build/check-versions.js    
npm notice 163B  config/dev.env.js
npm notice 2.0kB config/index.js
npm notice 879B  src/router/index.js        
npm notice 482B  src/main.js
npm notice 65B   config/prod.env.js
npm notice 2.7kB build/utils.js
npm notice 575B  build/vue-loader.conf.js
npm notice 2.2kB build/webpack.base.conf.js 
npm notice 3.1kB build/webpack.dev.conf.js
npm notice 5.2kB build/webpack.prod.conf.js
npm notice 1.9kB package.json
npm notice 481B  README.md
npm notice 6.8kB build/logo.png
npm notice 6.8kB src/assets/logo.png
npm notice 343B  src/App.vue
npm notice 4.2kB src/components/***.vue
npm notice 604B  src/components/***.vue
npm notice 269B  src/components/***.vue
npm notice 1.2kB src/components/***.vue
npm notice 631B  src/components/***.vue
npm notice 633B  src/components/***.vue
npm notice 820B  src/components/***.vue
npm notice === Tarball Details ===
npm notice name:          ***
npm notice version:       1.0.0
npm notice package size:  19.7 kB
npm notice unpacked size: 53.2 kB
npm notice shasum:        c81194aec18b6d2e072813b62e2886265a3163f4
npm notice integrity:     sha512-gPlPMEYM2i4l3[...]dvT6BDBVyPYDQ==
npm notice total files:   31
npm notice
npm ERR! code EPRIVATE
npm ERR! This package has been marked as private
npm ERR! Remove the 'private' field from the package.json to publish it.

npm ERR! A complete log of this run can be found in:
npm ERR!     D:\nodejs\node_cache_logs\2020-05-20T15_24_24_490Z-debug.log

从这儿能够看到发布失败。根据错误提示可知道,当前包已经被标记为 private,从 package.json 中移除 private 字段才能够发布。

Linux 安装

安装 npm
yum install npm

在这里插入图片描述

安装 verdaccio
npm install verdaccio

在这里插入图片描述

在这里插入图片描述

npm install -g verdaccio

在这里插入图片描述

verdaccio

启动报错,原因如下:

在这里插入图片描述

(图片来自:verdaccio.org

更新 node
npm install -g n
n lts

在这里插入图片描述

输出环境变量,查看新的路径是否定义在 PATH 的路径下:

echo $PATH 

在这里插入图片描述

node 更新后正常运行

在这里插入图片描述

使用 IP 依然连不上,查看端口状态:

在这里插入图片描述

在这里插入图片描述

cd /root/.config/verdaccio/
vim config.yaml

在这里插入图片描述

修改完后再次启动,对比查看:

在这里插入图片描述

再次使用公网 IP 访问,内网的可以使用局域网 IP 访问:

在这里插入图片描述

添加用户

按照首页说明,拷贝命令,执行,添加用户:

npm adduser --registry http://***.***.***.***:4873

在这里插入图片描述

登录:

在这里插入图片描述

如果从公司回家办公,依然想要使用公司添加的用户信息,如何操作?直接安装发布包肯定是不现实的,因为家里的电脑没有连接配置的 verdaccio,如何操作呢?

npm set registry="http://***.***.***.***:4873"
npm login

输入公司设置的账号信息后回车,可以看到对应的提示,logged in as chyd on http://***:4873 ,也就是说在这种情况下,登录成功。

pm2

守护进程

npm install -g pm2 --unsafe-perm
pm2 start `which verdaccio`

在这里插入图片描述

包如何管理,如何使用,关键是制作、发布
pm2 start verdaccio 

安装 nrm

npm install -g nrm

在这里插入图片描述

(注意:处理敏感信息)

nrm ls

在这里插入图片描述

制作包:创建一个文件夹。

在这里插入图片描述

假设创建一个输入框,带有回车事件的输入框,进入 input 的目录:

cd pkg/input
npm init

在这里插入图片描述

这个时候能看到 pkg/input 文件夹下多了一个 package.json 的文件。

配置自己配置的 verdaccio:

npm set registry http://***:4873
npm publish

提示错误:

在这里插入图片描述

添加用户:

npm adduser --registry http://***:4873

在这里插入图片描述

在这里插入图片描述

再次发布:

在这里插入图片描述

可以看到:

在这里插入图片描述

在这里插入图片描述

修改后再发布:

在这里插入图片描述

npm ERR! code EPUBLISHCONFLICT
npm ERR! publish fail Cannot publish over existing version.
npm ERR! publish fail Update the 'version' field in package.json and try again.
npm ERR! publish fail
npm ERR! publish fail To automatically increment version numbers, see:
npm ERR! publish fail     npm help version
npm unpublish inputenter --force

在这里插入图片描述

在这里插入图片描述

刷新刚才看到的发布页面,已经找不到 inputenter 的包了。

在这里插入图片描述

再次发布:

在这里插入图片描述

在这里插入图片描述

可以看到添加的 README.md 对应于 README 上的显示。

发布完成后尝试安装:

npm install inputenter

在这里插入图片描述

查看当前的注册列表:

nrm ls

在这里插入图片描述

nrm add cydnpm http://***:4873

在这里插入图片描述

在这里插入图片描述

回到 project 的根目录,执行安装命令:

在这里插入图片描述

在 node_modules 下可以看到对应的包文件:

在这里插入图片描述

这次的分享到这儿基本结束了,希望你可以用更快的,更简单的方法,去克服工作中遇到的问题。欢迎共同学习,携手共进。