小菜鸡成长之路(vue组件库开发)

294 阅读6分钟

教学视频

课堂连接

练习代码

链接

个人笔记

一、组件库介绍

1. 开源组件库

  • Element-UI
  • IView

2. 组件开发方式CDD

  • 自下而上
  • 从组件级别开始,到页面级别结束

3. CDD的好处

  • 组件在最大程度上被重用
  • 并行开发
  • 可视化测试

二、处理组件边界情况

vue中处理组件边界情况的API

1. $root

01-root.vue

<template>
  <div>
    <!--
      小型应用中可以在 vue 根实例里存储共享数据
      组件中可以通过 $root 访问根实例
    -->
    $root.title:{{ $root.title }}
    <br>
    <button @click="$root.handle">获取 title</button>&nbsp;&nbsp;
    <button @click="$root.title = 'Hello $root'">改变 title</button>
  </div>
</template>
<script>
export default {
}
</script>
<style>
</style>

2. parent/parent / children

  • $parent

01-parent.vue

<template>
  <div class="parent">
    parent
    <child></child>
  </div>
</template>
<script>
import child from './02-child'
export default {
  components: {
    child
  },
  data () {
    return {
      title: '获取父组件实例'
    }
  },
  methods: {
    handle () {
      console.log(this.title)
    }
  }
}
</script>
<style>
.parent {
  border: palegreen 1px solid;
}
</style>

02-child.vue

<template>
  <div class="child">
    child<br>
    $parent.title:{{ $parent.title }}<br>
    <button @click="$parent.handle">获取 $parent.title</button>
    <button @click="$parent.title = 'Hello $parent.title'">改变 $parent.title</button>
  
    <grandson></grandson>
  </div>
</template>
<script>
import grandson from './03-grandson'
export default {
  components: {
    grandson
  }
}
</script>
<style>
.child {
  border:paleturquoise 1px solid;
}
</style>

03-grandson.vue

<template>
  <div class="grandson">
    grandson<br>
    $parent.$parent.title:{{ $parent.$parent.title }}<br>
    <button @click="$parent.$parent.handle">获取 $parent.$parent.title</button>
    <button @click="$parent.$parent.title = 'Hello $parent.$parent.title'">改变 $parent.$parent.title</button>
  </div>
</template>
<script>
export default {
}
</script>
<style>
.grandson {
  border:navajowhite 1px solid;
}
</style>
  • $children
    通过数组索引获取对应的children

3. $ref

01-parent.vue

<template>
  <div>
    <myinput ref="mytxt"></myinput>
    <button @click="focus">获取焦点</button>
  </div>
</template>
<script>
import myinput from './02-myinput'
export default {
  components: {
    myinput
  },
  methods: {
    focus () {
      this.$refs.mytxt.focus()
      this.$refs.mytxt.value = 'hello'
    }
  }
  // mounted () {
  //   this.$refs.mytxt.focus()
  // }
}
</script>
<style>
</style>
• 1
• 2
• 3

02-myinput.vue

<template>
  <div>
    <input v-model="value" type="text" ref="txt">
  </div>
</template>
<script>
export default {
  data () {
    return {
      value: 'default'
    }
  },
  methods: {
    focus () {
      this.$refs.txt.focus()
    }
  }
}
</script>
<style>
</style>

4. 依赖注入provide/inject

注意:inject进来的数据是非响应式的。

01-parent.vue

<template>
  <div class="parent">
    parent
    <child></child>
  </div>
</template>
<script>
import child from './02-child'
export default {
  components: {
    child
  },
  provide () {
    return {
      title: this.title,
      handle: this.handle
    }
  },
  data () {
    return {
      title: '父组件 provide'
    }
  },
  methods: {
    handle () {
      console.log(this.title)
    }
  }
}
</script>
<style>
.parent {
  border: palegreen 1px solid;
}
</style>

02-child.vue

<template>
  <div class="child">
    child<br>
    title:{{ title }}<br>
    <button @click="handle">获取 title</button>
    <button @click="title='xxx'">改变 title</button>
    <grandson></grandson>
  </div>
</template>
<script>
import grandson from './03-grandson'
export default {
  components: {
    grandson
  },
  inject: ['title', 'handle']
}
</script>
<style>
.child {
  border:paleturquoise 1px solid;
}
</style>

03-grandson.vue

<template>
  <div class="grandson">
    grandson<br>
    title:{{ title }}<br>
    <button @click="handle">获取 title</button>
    <button @click="title='yyy'">改变 title</button>
  </div>
</template>
<script>
export default {
  inject: ['title', 'handle']
}
</script>
<style>
.grandson {
  border:navajowhite 1px solid;
}
</style>

三、attrs/attrs / listeners

$attrs:把父组件中非prop属性绑定到内部组件

$listeners:把父组件中的的DOM对象的原生事件绑定到内部组件

01-parent.vue

<template>
  <div>
    <!-- <myinput
      required
      placeholder="Enter your username"
      class="theme-dark"
      data-test="test">
    </myinput> -->
    <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>
<style>
</style>

02-myinput.vue

<template>
  <!--
    1. 从父组件传给自定义子组件的属性,如果没有 prop 接收
       会自动设置到子组件内部的最外层标签上
       如果是 class 和 style 的话,会合并最外层标签的 class 和 style 
  -->
  <!-- <input type="text" class="form-control" :placeholder="placeholder"> -->
  <!--
    2. 如果子组件中不想继承父组件传入的非 prop 属性,可以使用 inheritAttrs 禁用继承
       然后通过 v-bind="$attrs" 把外部传入的非 prop 属性设置给希望的标签上
       但是这不会改变 class 和 style
  -->
  <!-- <div>
    <input type="text" v-bind="$attrs" class="form-control">
  </div> -->
  <!--
    3. 注册事件
  -->
  <!-- <div>
    <input
      type="text"
      v-bind="$attrs"
      class="form-control"
      @focus="$emit('focus', $event)"
      @input="$emit('input', $event)"
    >
  </div> -->
  <!--
    4. $listeners
  -->
  <div>
    <input
      type="text"
      v-bind="$attrs"
      class="form-control"
      v-on="$listeners"
    >
  </div>
</template>
<script>
export default {
  // props: ['placeholder', 'style', 'class']
  // props: ['placeholder']
  inheritAttrs: false
}
</script>
<style>
</style>

四、快速原型开发

  • VueCLI中提供了一个插件可以进行原型快速开发
  • 需要先额外安装一个全局的扩展:npm install -g @vue/cli-service-global

1. Vue serve

  • Vue serve如果不指定参数默认会在当前目录找一下的入口文件

  • main.js、index.js、App.vue、app.vue

  • 可以指定要架子啊的组件

  • vue serve ./src/login.vue

写一个vue组件,App.vue

<template>
  <div>
    Hello vue
  </div>
</template>
<script>
export default {
}
</script>
<style>
</style>

然后执行vue serve

启动了一个服务,打开终端给出的地址,就可以看到这个组件的页面了。

2. ElementUI

安装ElementUI

  • 初始化package.json:npm init -y
  • 安装ElementUI:vue add element
  • 加载ElementUI,使用Vue.use()安装插件

五、组件开发

1. 步骤条组件

  • 第三方组件
  • 基础组件
  • 业务组件

Steps-test.vue

<template>
  <div>
    <Steps
      :count="count"
      :active="active"
    ></Steps>
    <button @click="next">下一步</button>
  </div>
</template>
<script>
import Steps from './Steps'
export default {
  components: {
    Steps
  },
  data () {
    return {
      count: 4,
      active: 0
    }
  },
  methods: {
    next () {
      this.active++
    }
  }
}
</script>
<style>
</style>

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>
<style>
</style>

steps.css

.lg-steps {
  position: relative;
  display: flex;
  justify-content: space-between;
}
.lg-steps-line {
  position: absolute;
  height: 2px;
  top: 50%;
  left: 24px;
  right: 24px;
  transform: translateY(-50%);
  z-index: 1;
  background: rgb(223, 231, 239);
}
.lg-step {
  border: 2px solid;
  border-radius: 50%;
  height: 32px;
  width: 32px;
  display: flex;
  justify-content: center;
  align-items: center;
  font-weight: 700;
  z-index: 2;
  background-color: white;
  box-sizing: border-box;
}

2. 表单组件

整体结构

  • Form
  • FormItem
  • Input
  • Button

Input 组件验证

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

Form-test.vue

<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>

form/Form.vue

<template>
  <div>
    <form>
      <slot></slot>
    </form>
  </div>
</template>
<script>
export default {
  name: 'LgForm',
  provide () {
    return {
      form: this
    }
  },
  props: {
    model: {
      type: Object
    },
    rules: {
      type: Object
    }
  },
  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>
<style>
</style>

form/FormItem.vue

<template>
  <div>
    <label :for="prop">{{label}}</label>
    <div>
      <slot></slot>
      <p v-if="errMessage">{{errMessage}}</p>
    </div>
  </div>
</template>
<script>
import AsyncValidator from 'async-validator'
export default {
  name: 'LgFormItem',
  inject: ['form'],
  props: {
    label: {
      type: String
    },
    prop: {
      type: String
    }
  },
  mounted () {
    this.$on('validator', () => {
      this.validate()
    })
  },
  data () {
    return {
      errMessage: ''
    }
  },
  methods: {
    validate () {
      if (!this.prop) return
      const value = this.form.model[this.prop]
      const rules = this.form.rules[this.prop]
      const descriptor = { [this.props]: rules }
      const validator = new AsyncValidator(descriptor)
      return validator.validate({ [this.prop]: value }, errors => {
        if (errors) {
          this.errMessage = errors[0].message
        } else {
          this.errMessage = ''
        }
      })
    }
  }
}
</script>
<style>
</style>

form/Button.vue

<template>
  <div>
    <button @click="handleClick">
      <slot></slot>
    </button>
  </div>
</template>
<script>
export default {
  name: 'LgFButton',
  methods: {
    handleClick (event) {
      this.$emit('click', event)
      event.preventDefault()
    }
  }
}
</script>
<style>
</style>

form/Input.vue

<template>
  <div>
    <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 (event) {
      this.$emit('input', event.target.value)
      const findParent = parent => {
        while (parent) {
          if (parent.$options.name === 'LgFormItem') {
            break
          }
          parent = parent.$parent
        }
        return parent
      }
      const parent = findParent(this.$parent)
      if (parent) {
        parent.$emit('validator')
      }
    }
  }
}
</script>
<style>
</style>

六、Monorepo

1. 两种项目的组织方式

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

2. Monorepo结构

image

七、Storybook

  • 可视化的组件展示平台

  • 在格力的开发环境中,以交互的方式展示组件

  • 独立开发组件

  • 支持的框架

  • React、React Native、Vue、Angular

  • Ember、HTML、Svelte、Mithril、Riot

1. Storybook安装

  • 自动安装

  • npx -p @storybook/cli sb init --type vue

  • yarn add vue 使用yarn来安装依赖,因为后面会用到yarn的工作区

  • yarn add vue-loader vue-template-compiler --dev

  • 手动安装

自动安装完成之后,执行yarn storybook启动项目

image

还可以执行yarn build storybook进行打包,生成storybook-static静态文件目录

2. 使用storybook写组件

八、yarn workspaces

1. 开启yarn 的工作区

  • 项目根目录的package.json

    "private": true, "workspaces": [ "./packages/*" ]

2. yarn workspaces 使用

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

Monorepo项目都会结合workspaces来使用,workspaces可以方便管理依赖,将每个工作区中的依赖提升到根目录中的node_modules中。workspaces还可以管理scripts命令。

九、Lerna

1. Lerna介绍

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

2. Lerna使用

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

先执行:yarn global add lerna

然后在项目中执行lerna init

然后在package.json中scripts里增加: "lerna": "lerna publish"

然后创建一个.gitignore,提交git初始化

echo node_modules > .gitignore
git add .
git commit -m"init"

然后在GitHub创建一个空仓库,我已经创建好了,然后执行:

git remote add origin git@github.com:2604150210/lg-element.git
git push -u origin master

使用npm whoami查看当前登录npm的用户名

如果没有出现用户名

npm adduser

使用npm config get registry 查看npm镜像源

registry.npm.taobao.org/

发现是淘宝镜像,那要改回来:

npm config set registry [http://registry.npmjs.org/](http://registry.npmjs.org/)

完成准备工作,git仓库和npm账号登陆后,开始正式使用lerna

yarn lerna

如果出现没有权限发布,不要惊慌,是包名重复了,多换几个名字就好了。

image.png

最后发布了六个模块

image.png

十、Vue组件的单元测试

使用单元测试工具对组件的状态和行为进行测试,确保组件发布之后,在项目中使用组件过程中不会出现错误。

1. 组件单元测试的好处

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

2. 安装依赖

  • Vue Test Utils

  • Jest

  • vue-jest

  • babel-jest

  • 安装

  • yarn add jest @vue/test-utils vue-jest babel-jest -D -W

  • -D是开发依赖,-W是安装在项目根目录下

3. 配置测试脚本

  • packge.json

    "scripts": { "test": "jest" }

4. Jest配置文件

  • jest.config.js

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

5. Babel配置文件

  • babel.config.js

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

  • Babel桥接
    yarn add babel-core@bridge -D -W

十一、Vue组件单元测试–Jest

1. Jest常见API

  • 全局函数

  • describe(name, fn) 把相关测试组合在一起

  • test(name, fn) 测试方法

  • expect(value) 断言

  • 匹配器

  • toBe(value) 判断值是否相等

  • toEqual(obj) 判断对象是否相等

  • toContain(value) 判断数组或者字符串是否包含

  • 快照

  • toMatchSnapshot()

2. Vue Test Utils 常用API

  • mount() 创建一个包含被挂载和渲染的Vue组件的Wrapper

  • Wrapper

  • vm : wrapper 包裹的组件实例

  • props() : 返回Vue实例选项中的props对象

  • html() : 组件生成的HTML标签

  • find() : 通过选择器返回匹配到的组件中的DOM元素

  • trigger() : 触发DOM原生事件,自定义事件 wrapper.vm.$emit()

执行yarn test进行测试

测试通过的情况:

image

测试不通过的情况:

image

增加密码框测试:

image

属性测试:

image

快照测试:

先生成快照

image

生成的快照会存到同级目录的__snapshots__/input.test.js.snap文件中

image

然后修改34行的type为text

image

再重新执行yarn test,此时将会进行快照对比

image

执行yarn test -u可以把快照文件删掉重新生成一个快照

image

之前的快照是type=“password”,现在就变成了type="password"了。

十二、Rollup打包

1. Rollup

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

2. 安装依赖

  • Rollup

  • rollup-plugin-terser

  • rollup-plugin-vue@5.1.9

  • Vue-template-compiler

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

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({
        css: true,
        compileTemplate: true
      }),
      terser()
    ]
  }
]

然后在每个组件的package.json中配置脚本命令"build": "rollup -c"

执行:

yarn workspace jiailing-button run build

image

一个一个组件打包太过繁琐,现在在根目录下配置统一打包

安装依赖:

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({
    
    css: true,
    
    compileTemplate: true
  }),
  json(),
  nodeResolve(),
  postcss({
    // 把css插入到style中
    // inject: true,
    // 把css放到和js同一级目录
    extract: true
  })
]
// 如果不是开发环境,开启压缩
isDev || plugins.push(terser())
// pacakges 文件夹路径
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),// 读取package.json中的main属性
          format: 'cjs'
        },
        {
          exports: 'auto',
          file: path.resolve(root, item, pkg.module), // 读取package.json中的module属性
          format: 'es'
        }
      ],
      plugins: plugins
    }
  })

在package.json中配置脚本命令"build": "rollup -c"

在每个组件的package.json里配置main和module属性

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

执行yarn build

每个组件里的dist路径下生成了es文件夹和cjs文件夹

image

十三、设置环境变量

安装cross-env,可以跨平台配置环境变量

yarn add cross-env -D -W

修改package.json中的打包命令

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

执行yarn build:prod生成的代码是压缩过的

执行yarn build:dev生成的代码是没有压缩过的

十四、清理

在package.json中配置命令"clean": "lerna clean"

可以删除组件中的node_modules

image

现在要来安装rimraf,来删除指定的目录,dist

yarn add rimraf -D -W

在每个组件的package.json中配置命令:

“del”: “rimraf dist”

在终端中执行yarn workspaces run del来执行每个组件中的del命令

十五、基于模板生成组件基本结构

安装plop

yarn add plop -W -D

十六、发布

yarn build:prod
npm whoami
git add .
git commit -m"最后发布"
yarn lerna

备注:执行yarn lerna之前必须先commit才会发布成功,否则被视为代码没有更新,则不发布包