Vue.js 框架源码与进阶 - 封装 Vue.js 组件库

本文详细介绍了Vue.js组件开发的基础,包括CDD方法、处理组件边界情况、$attrs/$listeners、快速原型开发和使用ElementUI。进一步讨论了组件分类、步骤条和表单组件的开发,以及组件库管理,如Monorepo、Storybook和Lerna。此外,还涵盖了单元测试、Rollup打包和环境变量设置等关键点。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

14.1 组件开发基础

CDD 基础

  • CDD(Component-Driven Development)
    • 自上而下
    • 从组件级别开始,到页面级别结束
      • 先从相对完的的设计中抽象出来组件,先隔离开发组件然后再开发页面

CDD的好处

  • 组件在最大程度被重用
  • 并行开发
    • 对单个组件的开发使用CDD可以让以页面级开发无法实现的方式在不同团队之间共享任务:开发相对隔离的组件
  • 可视化测试
    • 通过一些工具可以直接浏览一些组件,而不需要到业务系统中再测试组件,可以对不同组件的状态进行测试

处理组件的边界情况

样例代码

src/main.js:在 Vue 的根实例中设置了title属性,在下面设置了handle方法打印了title的值

  • $root
    • src/01-root/:使用插着表达式把$root.title打印了出来,点击第一个按钮调用$root.handle获取title的值并打印到控制台,点击第二个按钮改变title 查看其是否为响应式的
      • 建议在组件比较少的小型项目中使用,实际开发中如果状态较多会难以维护
  • $parent / $children
    • src/02-parent/:创建了三个嵌套组件,在chile.vue中使用$parent.title获取parent.vuetitle并显示出来,同时也嵌套了第三个组件grandson.vue并使用$parent.$parent.title获取title显示出来
      • 通过$parent可以操作父组件的成员,它可以替换prop使用,prop是不允许修改的,通过$parent获取的成员可以直接修改。也就是在子组件中可以直接修改父组件中的成员,如果应用复杂的话会导致我们难以维护,而且嵌套过多使用也不方便
    • src/03-child/:这里定义了三个组件,parent中使用了children1children2两个组件,当点击按钮分别打印$children数组并分别获取对应索引的的组件,当拿到子组件后可以访问里面的title属性以及handle方法
  • $refs
    • src.04-ref/:定义了两个组件parentmyinputmyinput中放了一个input标签,通过v-model绑定了value属性,同时设置了ref="txt"。我们希望点击按钮时让子组件的input获取焦点,我们定义了focus方法this.$refs.txt.focus()来获取input标签,此处获取的为DOM对象。在parent组件中使用了myinput并设置ref="mytxt",当点击按钮通过this.$refs.mytxt.focus()获取自定义组件调用其focus方法
      • $refs可以用在两个地方:如果用在普通html标签上,通过$refs获取到的就是普通DOM对象;如果用在子组件上获取到的就是对应的子组件对象。需要注意的是我们需要等待组件渲染完毕之后在通过$refs获取子组件
  • 依赖注入 provide / inject
    • src/05-provide&inject:里面有三个组件和02-parent中的三个组件一致,我们希望在子组件中访问parent中的一些成员可以通过provide先在父组件中提供,再从子组件中通过inject注入,然后就可以在子组件中通过插值表达式展示
      • 需要注意的是:应该避免修改indect进来的成员,它不是响应式的。可以把依赖注入看做大范围的prop,父组件的成员在所有子组件、多层嵌套的子组件中都可以使用。依赖注入带来的负面影响是组件之间的耦合变高,子组件依赖父组件使重构变得更加困难

$attrs / $listeners

如果你需要开发自定义组件的话,你会用到这两个属性

  • $ attrs
    • 把父组件中非 prop 属性绑定到内部组件
  • $ liteners
    • 把组件中的 都没DOM对象的原生事件绑定到内部组件
  • src/06-attrs&listener:有两个组件,子组件myinput以及父组件parent

parent:

<template>
  <div>
    <myinput
      required
      placeholder="Enter your username"
      class="theme-dark"
      data-test="test">
    </myinput>
  </div>
</template>

<script>
import myinput from './02-myinput'
export default {
  components: {
    myinput
  }
}
</script>

myinput:

<template>
  <!--
    1. 从父组件传给自定义子组件的属性,如果没有 prop 接收
       会自动设置到子组件内部的最外层标签上
       如果是 class 和 style 的话,会合并最外层标签的 class 和 style 
  -->
  <input type="text" class="form-control">
</template>

<script>
export default {
}
</script>

image.png

如果设置了父组件中设置属性对应的prop

export default {
  props: ['placeholder', 'style', 'class']
}

image.png

我们可以发现此时placeholder没有设置成功,但是class却设置成功了,而且报了两个错误

image.png

它告诉我们classstyle是保留的属性,不能用在组件的prop

现在我们希望从父组件中接收的placeholder属性能够正常的设置到input标签上

<template>
  <input type="text" class="form-control" :placeholder="placeholder">
</template>

<script>
export default {
  props: ['placeholder']
}
</script>

image.png

如果设置prop来接受父组件中传递的属性需要自己在标签上绑定相应的属性,另外prop不能使用styleclass属性,如果不使用prop的话父组件默认传递过来的属性会绑定到template的根标签上。

如果inputdiv中包裹,此时会把父组件传递的属性设置给template的根元素,使用$attrs可以方便处理这种情况

<template>
  <div>
    <input type="text" class="form-control">
  </div>
</template>  

<script>
export default {
}
</script>

image.png

<template>
  <!--
    2. 如果子组件中不想继承父组件传入的非 prop 属性,可以使用 inheritAttrs 禁用继承
       然后通过 v-bind="$attrs" 把外部传入的非 prop 属性设置给希望的标签上

       但是这不会改变 class 和 style
  -->
  <div>
    <input type="text" v-bind="$attrs" class="form-control">
  </div>
</template>  

<script>
export default {
  inheritAttrs: false
}
</script>

image.png

我们演示了$attrs的使用,它可以让我们在子组件中更方便得控制父组件传过来的属性。

接下来来掩饰父组件给子组件传递事件:

parent.vue:

<template>
  <div>
    <myinput
      required
      placeholder="Enter your username"
      class="theme-dark"
      @focus="onFocus"
      @input="onInput"
      data-test="test">
    </myinput>
    <button @click="handle">按钮</button>
  </div>
</template>

<script>
import myinput from './02-myinput'
export default {
  components: {
    myinput
  },
  methods: {
    handle () {
      console.log(this.value)
    },
    onFocus (e) {
      console.log(e)
    },
    onInput (e) {
      console.log(e.target.value)
    }
  }
}
</script>

myinput.vue:

<template>
  <!--
    3. 注册事件
  -->
  <div>
    <input
      type="text"
      v-bind="$attrs"
      class="form-control"
      @focus="$emit('focus', $event)"
      @input="$emit('input', $event)"
    >
  </div>
</template>

<script>
export default {
  inheritAttrs: false
}
</script>

我们希望父组件注册的事件能够被触发

image.png

现在假设我们给这个文本框注册的事件很多,这样写就很麻烦。所以接下来我们通过$listeners来简化这件事

<template>
  <!--
    4. $listeners
  -->
  <div>
    <input
      type="text"
      v-bind="$attrs"
      class="form-control"
      v-on="$listeners"
    >
  </div>
</template>

<script>
export default {
  inheritAttrs: false
}
</script>

这里把注册文本框本身的事件和触发自定义事件换成了v-on="$listeners"
image.png

快速原型开发

Vue/cli 提供了快速原型开发的工具,它可以让我们很方便地运行一个单文件组件而不需要关心额外的配置

  • VueCLI 中提供了一个插件可以进行快速原型开发

  • 需要先额外安装一个全局的扩展

npm install -g @vue/cli-service-global
  • 使用 vue serve 快速查看组件运行效果

vue serve

  • vue serve 如果不指定参数默认会在当前目录找到以下的入口文件
    • main.js、index.js、App.vue、app.vue
  • 可以指定要加载的组件
    • vue serve ./src/login.vue
<template>
  <div>
    Hello Vue
  </div>
</template>

<script>
export default {

}
</script>

<style>

</style>
vue serve

image.png

快速原型开发 - ElementUI

我们除了可以从零开发组件外,还可以在第三方组件的基础上二次开发:比如在ElementUI的基础上开发自己的组件

安装 ElementUI

  • 初始化 package.json

    • npm init -y
      
  • 安装 ElementUI

    • vue add element
      
  • 加载 ElementUI,使用 Vue.use() 安装插件

接下来我们使用 ElementUI 做一个登录的组件:

image.png

删除掉src下多余的文件,此处不需要

在使用 ElementUI 之前,首先导入 ElementUI 注册插件

创建入口文件 main.js

import Vue from 'vue'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import Login from './src/Login.vue'

Vue.use(ElementUI)

new Vue({
  el: '#app',
  render: h => h(Login)
})

Login.vue 组件

vue serve

image.png

14.2 组件开发

组件分类

  • 第三方组件:ElemenUI、iView
  • 基础组件:文本框、按钮、表单
  • 业务组件:结合特定的行业使用场景,可以根据用户的行为输出特定的界面

如果们要开发的应用对界面的要求不高,我们可以直接使用第三方组件;

如果对组件的样式有比较高的要求,或者有一套自己的使用标准,则需要开发自己的组件库,开发一套方便团队内部使用的基础组件、通用组件;

如果针对特定的行业例如财务、餐饮或者人力系统,会有针对特定业务可以抽象出来的组件,我们可以把它们抽象出来方便未来的重用,开发业务组件一般可以基于现有的组件比如第三方组件,在第三方组件的基础上进行开发。

步骤条组件

  • 可以引导用户按照指定的步骤完成任务

src/steps.css 样式

src/Steps.vue

<template>
  <div class="lg-steps">
    <div class="lg-steps-line"></div>
    <div
      class="lg-step"
      v-for="index in count"
      :key="index"
      :style="{ color: active >= index ? activeColor : defaultColor }"
    >
      {{ index }}
    </div>
  </div>
</template>

<script>
import './steps.css'
export default {
  name: 'LgSteps',
  props: {
    count: {
      type: Number,
      default: 3
    },
    active: {
      type: Number,
      default: 1
    },
    activeColor: {
      type: String,
      default: 'red'
    },
    defaultColor: {
      type: String,
      default: 'green'
    }
  }
}
</script>
vue serve src/Steps.vue

image.png

src/Steps-test.vue

<template>
  <div>
    <steps :count="count" :active="active"></steps>
    <button @click="next">下一步</button>
  </div>
</template>

<script>
import Steps from './Steps.vue'
export default {
  components: {
    Steps
  },
  data () {
    return {
      count: 4,
      active: 0
    }
  },
  methods: {
    next () {
      this.active++
    }
  }
}
</script>

<style>

</style>
vue serve ./src/Steps-test.vue

image.png

表单组件

整体结构

  • Form
  • FormItem
  • Input
  • Button

src/form/Form.vue

<template>
  <form>
    <slot></slot>
  </form>
</template>

<script>
export default {
  name: 'LgForm',
  props: {
    model: {
      type: Object
    },
    rules: {
      type: Object
    }
  }
}
</script>

src/form/FormItem.vue

<template>
  <div>
    <label>{{ label }}</label>
    <div>
      <slot></slot>
      <p v-if="errMessage">{{ errMessage }}</p>
    </div>
  </div>
</template>

<script>
export default {
  name: 'LgFormItem',
  props: {
    label: {
      type: String
    },
    prop: {
      type: String
    }
  },
  data () {
    return {
      errMessage: ''
    }
  }
}
</script>

src/form/Input.vue

<template>
	<div>
    <!-- 将父组件传递过来的属性展开给 input -->
    <input v-bind="$attrs" :type="type" :value="value" @input="handleInput">
  </div>
</template>

<script>
export default {
  name: 'LgInput',
  inheritAttrs: false, // 禁用父组件传递过来的属性
  props: {
    value: {
      type: String
    },
    type: {
      type: String,
      default: 'text'
    }
  },  
  methods: {
    handleInput (evt) {
      this.$emit('input', evt.target.value)
    }
  }  
}
</script>

src/form/Button.vue

<template>
  <div>
    <button @click="handleClick"><slot></slot></button>
  </div>
</template>

<script>
export default {
  name: 'LgButton',
  methods: {
    handleClick (evt) {
      this.$emit('click', evt)
      evt.preventDefault()
    }
  }
}
</script>

src/Form-test.vue:拿Login.vue修改而来,将el-form修改为lg-from,并做出一些修改:

<template>
  <lg-form class="form" ref="form" :model="user" :rules="rules">
    <lg-form-item label="用户名" prop="username">
      <!-- <lg-input v-model="user.username"></lg-input> -->
      <lg-input :value="user.username" @input="user.username=$event" placeholder="请输入用户名"></lg-input>
    </lg-form-item>
    <lg-form-item label="密码" prop="password">
      <lg-input type="password" v-model="user.password"></lg-input>
    </lg-form-item>
    <lg-form-item>
      <lg-button type="primary" @click="login">登 录</lg-button>
    </lg-form-item>
  </lg-form>
</template>

<script>
import LgForm from './form/Form'
import LgFormItem from './form/FormItem'
import LgInput from './form/Input'
import LgButton from './form/Button'
export default {
  components: {
    LgForm,
    LgFormItem,
    LgInput,
    LgButton
  },
  data () {
    return {
      user: {
        username: '',
        password: ''
      },
      rules: {
        username: [
          {
            required: true,
            message: '请输入用户名'
          }
        ],
        password: [
          {
            required: true,
            message: '请输入密码'
          },
          {
            min: 6,
            max: 12,
            message: '请输入6-12位密码'
          }
        ]
      }
    }
  },
  methods: {
    login () {
      console.log('button')
      // this.$refs.form.validate(valid => {
      //   if (valid) {
      //     alert('验证成功')
      //   } else {
      //     alert('验证失败')
      //     return false
      //   }
      // })
    }
  }
}
</script>

<style>
  .form {
    width: 30%;
    margin: 150px auto;
  }
</style>

image.png

表单验证

src/form/Form.vue

<script>
export default {
  name: 'LgForm',
  provide () {
    return {
      form: this
    }
  },
  ...
}
</script>

src/form/FormItem.vue

<script>
export default {
  name: 'LgFormItem',
  inject: ['form'],
  ...
}
</script>
npm install async-validator

src/form/FormItem.vue

<script>
import AsyncValidator from 'async-validator'
export default {
  name: 'LgFormItem',
  ...
  methods: {
    validate () {
      if (!this.prop) return
      const value = this.form.model[this.prop]
      const rules = this.form.rules[this.prop]

      const descriptor = { [this.prop]: rules }
      const validator = new AsyncValidator(descriptor)
      return validator.validate({ [this.prop]: value }, errors => {
        if( errors) {
          this.errMessage = errors[0].message
        } else {
          this.errMessage = ''
        }
      })
    }
  }
}
</script>

Input 组件验证

  • Input 组件中触发自定义事件 validate
  • FormItem 渲染完毕注册自定义事件 validate

src/form/Input.vue

<script>
export default {
  name: 'LgInput',
  ...
  methods: {
    handleInput (evt) {
      this.$emit('input', evt.target.value)
      const friendParent = parent => {
        while (parent) {
          if (parent.$options.name === 'LgFormItem') {
            break
          } else {
            parent = parent.$parent
          }
        }
        return parent
      }
      const parent = friendParent(this.$parent)
      if (parent) {
        parent.$emit('validate')
      }
    }
  }
}
</script>

src/form/FormItem.vue

<script>
...
export default {
  name: 'LgFormItem',
  ...
  mounted() {
    this.$on('validate', () => {
      this.validate()
    })
  },
  ...
}
</script>

image.png

src/Form-test.vue

<script>
  methods: {
    login () {
      // console.log('button')
      this.$refs.form.validate(valid => {
        if (valid) {
          alert('验证成功')
        } else {
          alert('验证失败')
          return false
        }
      })
    }
  }
</script>

src/form/Form.vue

<script>
export default {
  name: 'LgForm',
  ...
  methods: {
    validate (cb) {
      const tasks = this.$children
        .filter(child => child.prop)
        .map(child => child.validate())

      Promise.all(tasks)
        .then(() => cb(true))
        .catch(() => cb(false))
    }
  }
}
</script>

image.png

14.3 组件库管理

Monorepo

假设我们现在要开发一个组件库,其中有很多组件,当它开发完毕后还会发布到诸如NPM或者私有仓库让其他人去使用。

在使用ElementUI的时候我们可以完整地引用,如果只使用到部分组件,为了减少打包的体积我们会选择按需引用的方式,此时要安装babel的插件然后再配置比较麻烦。

我们开发的组件库为了让别人使用的方便,我们决定把每一个组件作为一个单独的包发布到NPM上,其他人在使用时可以只下载他所需要的组件。

两种项目的组织方式

  • Multirepo(Multiple Repository)
    • 每一个包对应一个项目
  • Monorepo(Monoltipe Repository)
    • 一个项目仓库中管理多个模块/包

包的结构

样例代码

image.png

  • __test__:测试代码目录
  • dist:打包的目录
  • src:源码目录
  • index.js:打包入口
  • LICENSE:版权信息
  • package.json:包的描述信息
  • README.md:文档

Storybook

  • 可视化的组件展示平台
  • 在隔离的开发环境中,以交互式的方式展示组件
  • 独立开发组件
  • 支持的框架
    • React、React Native、Vue、Angular、
    • Ember、HTML、Svelte、Mithril、Riot

Storybook 安装

  • 自动安装
    • npx -p @storybook/cli sb init --type vue
    • yarn add vue
    • yarn add vue-loader vue-template-compiler --dev
  • 手动安装

创建一个空项目lgelement执行上述操作

yarn storybook

image.png

接下来我们将之前的项目应用进来

.storybook/main.js

module.exports = {
  stories: ['../packages/**/*.stories.js'],
  addons: ['@storybook/addon-actions', '@storybook/addon-links'],
};

我们这里演示表单组件,先给input写一个简单的stories:渲染文本框、渲染密码框

input/stories/input.stories.js

import LgInput from '../'

export default {
  title: 'LgInput',
  component: 'LgInput'
}

export const Text = () => ({
  components: { LgInput },
  template: '<lg-input v-model="value"></lg-input>',
  data (){
    return {
      value: 'admin'
    }
  }
})

export const Password = () => ({
  components: { LgInput },
  template: '<lg-input type="password" v-model="value"></lg-input>',
  data (){
    return {
      value: 'admin'
    }
  }
})

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VokxlWR8-1614789141659)(https://i.loli.net/2021/03/04/shJ1cIiDbQRf9Wm.png)]

接下来进入fromitem文件夹,安装async-validator依赖

yarn add async-validator

form/stories/form.stories.js

import LgForm from '../'
import LgFormItem from '../../formitem'
import LgInput from '../../input'
import LgButton from '../../button'

export default {
  title: 'LgForm',
  component: LgForm
}

export const Login = () => ({
  components: { LgForm, LgFormItem, LgInput, LgButton },
  template: `
    <lg-form class="form" ref="form" :model="user" :rules="rules">
      <lg-form-item label="用户名" prop="username">
        <!-- <lg-input v-model="user.username"></lg-input> -->
        <lg-input :value="user.username" @input="user.username=$event" placeholder="请输入用户名"></lg-input>
      </lg-form-item>
      <lg-form-item label="密码" prop="password">
        <lg-input type="password" v-model="user.password"></lg-input>
      </lg-form-item>
      <lg-form-item>
        <lg-button type="primary" @click="login">登 录</lg-button>
      </lg-form-item>
    </lg-form>
  `,
  data () {
    return {
      user: {
        username: '',
        password: ''
      },
      rules: {
        username: [
          {
            required: true,
            message: '请输入用户名'
          }
        ],
        password: [
          {
            required: true,
            message: '请输入密码'
          },
          {
            min: 6,
            max: 12,
            message: '请输入6-12位密码'
          }
        ]
      }
    }
  },
  methods: {
    login () {
      // console.log('button')
      this.$refs.form.validate(valid => {
        if (valid) {
          alert('验证成功')
        } else {
          alert('验证失败')
          return false
        }
      })
    }
  }
})

image.png

yarn workspaces

开启yarn workspaces可以让我们在根目录中使用yarn install给所有的包统一安装依赖

  • 如果不同的包引用相同的第三方包只会下载一次并把相同的依赖提升到根目录的node_modules中减少重复
  • 如果不同的包引用的lodash版本不相同只会把相同版本的lodash提升到根目录的node_modules
  • npm不支持workspaces

开启 yarn 的工作区

  • 项目根目录的package.json
  "private": true,
  "workspaces": [
    "packages/*"
  ]

"private": true:将来提交到github或者发布到npm时禁止把当前根目录的内容进行提交。

yarn workspaces 使用

  • 给工作区根目录安装开发依赖
    • yarn add jest -D -W
  • 给指定工作区安装依赖
    • yarn workspace lg-button add lodash@4
  • 给所有工作区安装依赖
    • yarn install
yarn workspace lg-button add lodash@4

yarn workspace lg-form add lodash@4

yarn workspace lg-input add lodash@3

此时,除了lg-input的依赖安装在input包内,其余依赖均提升到了根目录包依赖内

Lerna

Lerna 可以方便我们把项目中的所有包统一发布

Lerna 介绍

  • Lerna 是一个优化使用gitnpm管理多包仓库的工作流工具
  • 用于管理具有多个包的JavaScript项目
  • 它可以一键把代码提交到gitnpm仓库

Lerna 使用

  • 全局安装
    • yarn global add lerna
  • 初始化
    • lerna init
  • 发布
    • lerna publish

初始化完成过后会做几件事情:

1.如果当前项目没有被git管理的话会进行git初始化

2.在项目根目录创建lerna.json的配置文件

3.在package.json中添加开发依赖确保别人获取我们的项目也可以正常工作

lerna.json记录当前项目初始化的版本以及所有管理包的路径

{
  "packages": [
    "packages/*"
  ],
  "version": "0.0.0"
}

package.json添加scripts命令"lerna": "lerna publish",然后我们需要把项目推送到仓库中并发布npm

Vue组件的单元测试

组件开发完毕发布之前,我们还应该对组件进行单元测试。

单元测试就是对一个函数的输入和输出进行测试,使用断言的方式,根据输入判断实际的输出和预测的输出是否相同。

使用单元测试的目的是用来发现模块内部可能存在的各种错误。组件的单元测试指的是使用单元测试工具对组件的各种状态和行为进行测试,确保组件发布之后在项目中使用组件的过程中不会导致程序出现错误。

组件单元测试的好处

  • 提供描述组件行为的文档
  • 节省动手测试的时间
  • 减少研发新特性时产生的bug
  • 改进设计
  • 促进重构

安装依赖

  • Vue Test Utils
    • Vue 官方提供的组件单元测试的官方库
  • Jest
    • Vue Test Utils 需要结合该单元测试框架一起使用,它和vue的结合最方便、配置最少
  • Vue-jest
    • 预处理器,用于把vue的单文件组件编译之后的结果交给js处理,Vue-jest支持单文件组件的大多数功能
  • Babel-jest
    • 测试中会使用到一些ESModule的语法和一些ES的新特性的语法,需要此插件对测试代码进行降级处理
  • 安装
    • yarn add jest @vue/test-utils vue-jest babel-jest -D -W

配置测试脚本package.json

"scripts": {
  "test": "jest",
  ...
}

Jest 配置文件jest.config.js

module.exports = {
  "testMatch": ["**/__tests__/**/*.[jt]s?(x)"],
  "moduleFileExtensions": [
    "js",
    "json",
    // 告诉 Jest 处理 `*.vue` 文件
    "vue"
  ],
  "transform": {
    // 用 `vue-jest` 处理 `*.vue` 文件
    ".*\\.(vue)$": "vue-jest",
    // 用 `babel-jest` 处理 js
    ".*\\.(js)$": "babel-jest" 
  }
}

Babel 配置文件babel.config.js

module.exports = {
  presets: [
    [
      '@babel/preset-env'
    ]
  ]
}

Babel 桥接

yarn add babel-core@bridge -D -W

Jest 常用 API

  • 全局函数
    • describe(name, fn):把相关测试组合在一起
    • test(name, fn):测试方法
    • expect(value):断言
  • 匹配器
    • toBe(value):判断值是否相等
    • toEqual(obj):判断对象是否相等
    • toContain(value):判断数组或者字符串中是否包含
  • 快照
    • toMatchSnapshot()

Vue Test Utils 常用 API

  • mount()
    • 创建一个包含被挂载和渲染的 Vue 组件 的 Wrapper
  • Wrapper
    • vm:Wrapper 包裹的组件实例
    • props():返回 Vue 实例选项中的 props 对象
    • html():组件生成的 HTML 标签
    • find():通过选择器返回匹配到的组件中的 DOM 元素
    • trigger():触发 DOM 原生事件,自定义事件 wrapper.vm.$emit()

packages/input/__tests__/input.test.js

import input from '../src/input.vue'
import { mount } from '@vue/test-utils'

describe('lg-input', () => {
  test('input-text', () => {
    const wrapper = mount(input)
    expect(wrapper.html()).toContain('input type="text"')
  })
  
  test('input-password', () => {
    const wrapper = mount(input, {
      propsData: {
        type: 'password'
      }
    })
    expect(wrapper.html()).toContain('input type="password"')
  })

  test('input-password', () => {
    const wrapper = mount(input, {
      propsData: {
        type: 'password',
        value: 'admin'
      }
    })
    expect(wrapper.props('value')).toBe('admin')
  })

  test('input-snapshot', () => {
    const wrapper = mount(input, {
      propsData: {
        type: 'text',
        value: 'admin'
      }
    })
    expect(wrapper.vm.$el).toMatchSnapshot()
  })
})

image.png

Rollup 打包

Rollup

  • Rollup 是一个模块打包器
  • Rollup 支持 Tree-shaking
  • 打包的结果比 Webpack 要小
  • 开发框架/组件库的时候使用 Rollup 更合适

安装依赖

  • Rollup
  • rollup-plugin-terser:对代码进行压缩
  • rollup-plugin-vue@5.1.9:把单文件组件编译成JS代码
  • vue-template-compiler

安装 Rollup 以及所需的插件

yarn add rollup rollup-plugin-terser rollup-plugin-vue@5.1.9 vue-template-compiler -D -W

Rollup 配置文件

button 目录中创建 rollup.config.js

import { terser } from 'rollup-plugin-terser'
import vue from 'rollup-plugin-vue'

module.exports = [
  {
    input: 'index.js',
    output: [
      {
        file: 'dist/index.js',
        format: 'es' // 打包的模块化方式
      }
    ],
    plugins: [
      vue({
        // Dynamically inject css as a <style> tag
        // 把单文件组件中的样式插入到 HTML 中的 style 标签
        css: true, 
        // Explicitly convert template to render function
        // 把组件转换成 render 函数
        compileTemplate: true
      }),
      terser() // 对代码进行压缩
    ]
  }
]

配置 build 脚本并运行

找到 button 包中的 package.jsonscripts 配置

"build": "rollup -c"

运行打包

yarn workspace lg-button run build

打包所有组件

yarn add @rollup/plugin-json rollup-plugin-postcss @rollup/plugin-node-resolve -D -W

项目根目录创建 rollup.config.js

import fs from 'fs'
import path from 'path'
import json from '@rollup/plugin-json'
import vue from 'rollup-plugin-vue'
import postcss from 'rollup-plugin-postcss'
import { terser } from 'rollup-plugin-terser'
import { nodeResolve } from '@rollup/plugin-node-resolve'

const isDev = process.env.NODE_ENV !== 'production'

// 公共插件配置
const plugins = [
  vue({
    // Dynamically inject css as a <style> tag
    css: true,
    // Explicitly convert template to render function
    compileTemplate: true
  }),
  json(),
  nodeResolve(),
  postcss({
    // 把 css 插入到 style 中
    // inject: true,
    // 把 css 放到和js同一目录
    extract: true
  })
]

// 如果不是开发环境,开启压缩
isDev || plugins.push(terser())

// packages 文件夹路径
const root = path.resolve(__dirname, 'packages')

module.exports = fs.readdirSync(root)
  // 过滤,只保留文件夹
  .filter(item => fs.statSync(path.resolve(root, item)).isDirectory())
  // 为每一个文件夹创建对应的配置
  .map(item => {
    const pkg = require(path.resolve(root, item, 'package.json'))
    return {
      input: path.resolve(root, item, 'index.js'),
      output: [
        {
          exports: 'auto',
          file: path.resolve(root, item, pkg.main),
          format: 'cjs'
        },
        {
          exports: 'auto',
          file: path.join(root, item, pkg.module),
          format: 'es'
        },
      ],
      plugins: plugins
    }
  })

根目录的 package.json 中配置 scripts

"build": "rollup -c"

在每一个包中设置 package.json 中的 mainmodule 字段

"main": "dist/cjs/index.js",
"module": "dist/es/index.js",
yarn build

image.png

设置环境变量

cross-env:跨平台设置环境变量

yarn add cross-env -D -W

根目录的 package.json 中配置 scripts

"build:prod": "cross-env NODE_ENV=production rollup -c",
"build:dev": "cross-env NODE_ENV=development rollup -c"

清理

  • 清理所有包中的 node_modules
  • 清理所有包中的dist

根目录的 package.json 中配置 scripts

"clean": "lerna clean"

image.png

这里再来使用一个第三方库rimraf,它可以指定要删除的目录

yarn add rimraf -D -W

在每一个包中设置 package.json 中的 scripts 字段

"del": "rimraf dist"
yarn workspaces run del

基于模板生成组件基本结构

  • 到这里我们创建了Monorepo的项目结构,在一个项目中管理多个包,这种方式更适合我们来管理组件库和发布每一个组件

  • 然后使用Storybook搭建项目可以让用户快速浏览组件

  • 使用yarn workspaces管理所有包的依赖

  • 使用Lerna发布项目,它可以帮我们把每一个包发布到NPM上

  • 最后还演示了测试和打包

如果我要创建一个新的组件该如何做呢?

我们可以把所有组件相同的部分提取出来制作一个模板,然后通过plop基于模板快速生成一个新的组件结构,方便后续大规模的组件开发。

安装 plop

yarn add plop -D -W

模板plop-template地址

plopfile.js

module.exports = plop => {
  plop.setGenerator('component', {
    description: 'create a custom component',
    prompts: [
      {
        type: 'input',
        name: 'name',
        message: 'component name',
        default: 'MyComponent'
      }
    ],
    actions: [
      {
        type: 'add',
        path: 'packages/{{name}}/src/{{name}}.vue',
        templateFile: 'plop-template/component/src/component.hbs'
      },
      {
        type: 'add',
        path: 'packages/{{name}}/__tests__/{{name}}.test.js',
        templateFile: 'plop-template/component/__tests__/component.test.hbs'
      },
      {
        type: 'add',
        path: 'packages/{{name}}/stories/{{name}}.stories.js',
        templateFile: 'plop-template/component/stories/component.stories.hbs'
      },
      {
        type: 'add',
        path: 'packages/{{name}}/index.js',
        templateFile: 'plop-template/component/index.hbs'
      },
      {
        type: 'add',
        path: 'packages/{{name}}/LICENSE',
        templateFile: 'plop-template/component/LICENSE'
      },
      {
        type: 'add',
        path: 'packages/{{name}}/package.json',
        templateFile: 'plop-template/component/package.hbs'
      },
      {
        type: 'add',
        path: 'packages/{{name}}/README.md',
        templateFile: 'plop-template/component/README.hbs'
      }
    ]
  })
}

根目录的 package.json 中配置 scripts

"plop": "plop"
yarn plop

image.png

超连接组件

接下来我们把基于模板生成的组件link实现以下

packages/link/src/link.vue

<template>
  <a
    :href="disabled ? null : href"
    :class="[disabled && 'disabled', !underline && 'no-underline']">
      <slot></slot>
    </a>
</template>

<script>
export default {
  name: 'LgLink',
  props: {
    href: {
      type: String
    },
    disabled: {
      type: Boolean,
      default: false
    },
    underline: {
      type: Boolean,
      default: false
    }
  }
}
</script>

<style scoped>
.disabled {
  cursor: not-allowed;
}
.no-underline {
  text-decoration: none;
}
</style>

packages/link/stories/link.stories.js

import LgLink from '../src/link.vue'

export default {
  title: 'LgLink',
  component: LgLink
}

export const Link = _ => ({
  components: { LgLink },
  template: `
    <div>
      <lg-link :disabled="true" href="http://www.baidu.com">baidu</lg-link>
    </div>
  `
})
yarn storybook

image.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值