从0到1开发组件库(vue3+ts)

4,446 阅读5分钟

最近想学习vue3的知识,而学一个东西最好的方式就是在用中学,这样能快速掌握必要的知识。所以我就想通过开发一个组件库的方式来学习,就有了下面的文章。

从这篇文章您能学到以下知识:

  • vue3项目的搭建
  • 组件开发过程
  • 组件源码展示
  • 组件库的部署和发布

1.使用Vite搭建官网

1.1 初始化项目

全局安装 create-vite-app

yarn global add create-vite-app@1.18.0
// 或者
npm i -g create-vite-app@1.18.0

创建项目目录

cva gulu-ui-1
// 或者
create-vite-app gulu-ui-1  
// 其中 gulu-ui-1 可以改为任意名称

安装vue-router

// 使用命令行查看 vue-router 所有版本号
npm info vue-router versions
// 安装依赖,当前最新版是4.0.6
yarn add vue-router@4.0.6

安装sass

yarn add sass -D

1.2 搭建官网

我们的官网主要有两个页面,一个是首页,一个是文档页。

初始化vue-router

创建两个页面,一个是首页Home.vue,一个是文档页面Doc.vue。这两个页面有一个共同的组件顶部导航Topnav.vue。接下来,我们来创建这几个组件。

views/Home.vue

<template>
  <div>
    <div class="topnavAndBanner">
      <TopNav />
      <div class="banner">
        <h1>轱辘UI</h1>
        <h2>一个用于学习的 UI 框架</h2>
        <p class="actions">
          <a href="https://github.com/liuzb30/gulu-ui">GitHub</a>
          <router-link to="/doc">开始</router-link>
        </p>
      </div>
    </div>
    <div class="features">
      <ul>
        <li>
          <svg>
            <use xlink:href="#icon-vue3"></use>
          </svg>
          <h3>基于 Vue 3</h3>
          <p>使用了 Vue 3 Composition API</p>
        </li>
        <li>
          <svg>
            <use xlink:href="#icon-ts"></use>
          </svg>
          <h3>基于 TypeScript</h3>
          <p>源代码采用 TypeScript 书写</p>
        </li>
        <li>
          <svg>
            <use xlink:href="#icon-code"></use>
          </svg>
          <h3>代码易读</h3>
          <p>每个组件的源代码都极其简洁</p>
        </li>
      </ul>
    </div>
  </div>
</template>

<script lang="ts">
import TopNav from "../components/TopNav.vue";
export default {
  components: {
    TopNav,
  },
};
</script>

<style lang="scss" scoped>
$green: #02bcb0;
$border-radius: 4px;
$color: #007974;

.topnavAndBanner {
  background: linear-gradient(
    145deg,
    rgba(227, 255, 253, 1) 0%,
    rgba(183, 233, 230, 1) 100%
  );
  clip-path: ellipse(80% 60% at 50% 40%);
}

.features {
  margin: 64px auto;
  width: 100%;

  @media (min-width: 800px) {
    width: 800px;
  }

  @media (min-width: 1200px) {
    width: 1200px;
  }

  @media (max-width: 500px) {
    ul {
      padding-left: 10px;
    }
  }

  > ul {
    display: flex;
    flex-wrap: wrap;

    > li {
      width: 400px;
      margin: 16px 0;
      display: grid;
      justify-content: start;
      align-content: space-between;
      grid-template-areas:
        "icon title"
        "icon text";
      grid-template-columns: 80px auto;
      grid-template-rows: 1fr auto;

      > svg {
        grid-area: icon;
        width: 64px;
        height: 64px;
      }

      > h3 {
        grid-area: title;
        font-size: 28px;
      }

      > p {
        grid-area: text;
      }
    }
  }
}

.banner {
  color: $color;
  padding: 100px 0;
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;

  > .actions {
    padding: 8px 0;

    a {
      margin: 0 8px;
      background: $green;
      color: white;
      display: inline-block;
      padding: 8px 24px;
      border-radius: $border-radius;

      &:hover {
        text-decoration: none;
      }
    }
  }
}
</style>

views/Doc.vue

<template>
  <div class="layout">
    <TopNav class="nav" toggleMenuButtonVisible />
    <div class="content">
      <aside v-if="menuVisible">
        <h2>文档</h2>
        <ol>
          <li>
            <router-link to="/doc/intro">介绍</router-link>
          </li>
          <li>
            <router-link to="/doc/install">安装</router-link>
          </li>
          <li>
            <router-link to="/doc/get-started">开始使用</router-link>
          </li>
        </ol>
        <h2>组件列表</h2>
        <ol>
          <li>
            <router-link to="/doc/switch">Switch 组件</router-link>
          </li>
          <li>
            <router-link to="/doc/button">Button 组件</router-link>
          </li>
          <li>
            <router-link to="/doc/dialog">Dialog 组件</router-link>
          </li>
          <li>
            <router-link to="/doc/tabs">Tabs 组件</router-link>
          </li>
        </ol>
      </aside>
      <main id="main">
        <router-view />
      </main>
    </div>
  </div>
</template>
<script lang="ts">
import TopNav from "../components/TopNav.vue";
import { inject, Ref } from "vue";
export default {
  components: { TopNav },
  setup() {
    const menuVisible = inject<Ref<boolean>>("menuVisible"); // get
    return { menuVisible };
  },
};
</script>
<style lang="scss" scoped>
$lightgreen: #bceeeb;
.layout {
  display: flex;
  flex-direction: column;
  height: 100vh;
  > .nav {
    flex-shrink: 0;
  }
  > .content {
    flex-grow: 1;
    padding-top: 60px;
    padding-left: 156px;
    @media (max-width: 500px) {
      padding-left: 0;
    }
  }
}
.content {
  display: flex;
  > aside {
    flex-shrink: 0;
  }
  > main {
    flex-grow: 1;
    padding: 16px;
    background: white;
  }
}
aside {
  background: $lightgreen;
  width: 150px;
  padding: 16px 0;
  position: fixed;
  top: 0;
  left: 0;
  padding-top: 70px;
  height: 100%;
  z-index: 10;
  > h2 {
    font-size: 22px;
    margin-bottom: 4px;
    padding: 0 16px;
  }
  > ol {
    > li {
      > a {
        display: block;
        padding: 8px 16px;
        text-decoration: none;
      }

      .router-link-active {
        background: white;
      }
    }
  }
}
main {
  overflow: auto;
}
</style>

顶部导航 components/TopNav.vue

<template>
  <div class="topnav">
    <router-link class="logo" to="/">
      <svg class="icon">
        <use xlink:href="#icon-wheel"></use>
      </svg>
    </router-link>
    <ul class="menu">
      <li><router-link to="/doc">文档</router-link></li>
    </ul>
    <svg v-if="toggleMenuButtonVisible" class="toggleAside" @click="toggleMenu">
      <use xlink:href="#icon-menu"></use>
    </svg>
  </div>
</template>
<script lang="ts">
import { inject, Ref } from "vue";
export default {
  props: {
    toggleMenuButtonVisible: {
      type: Boolean,
      default: false,
    },
  },
  setup() {
    const menuVisible = inject<Ref<boolean>>("menuVisible"); // get
    const toggleMenu = () => {
      menuVisible.value = !menuVisible.value;
    };
    return { toggleMenu };
  },
};
</script>
<style lang="scss" scoped>
$color: #007974;

.topnav {
  color: $color;
  display: flex;
  padding: 16px;
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 11;
  justify-content: center;
  align-items: center;
  background-color: white;

  > .logo {
    max-width: 6em;
    margin-right: auto;

    > svg {
      width: 32px;
      height: 32px;
    }
  }

  > .menu {
    display: flex;
    white-space: nowrap;
    flex-wrap: nowrap;

    > li {
      margin: 0 1em;
    }
  }

  > .toggleAside {
    width: 32px;
    height: 32px;
    position: absolute;
    left: 16px;
    top: 50%;
    transform: translateY(-50%);
    // background: fade-out(black, 0.9);
    display: none;
  }

  @media (max-width: 500px) {
    > .menu {
      display: none;
    }

    > .logo {
      margin: 0 auto;
    }

    > .toggleAside {
      display: inline-block;
    }
  }
}
</style>

接下来改造入口文件main.js和App.vue。

由于我们打算用ts来开发,所以,要把main.js改成main.ts。

main.ts

import { createApp } from 'vue'
import App from './App.vue'
import './index.scss'
import './assets/svg.js'
import router from './router'

const app = createApp(App)
app.use(router)
app.mount('#app')

svg.js用于显示图标,这个文件可以在github地址下载。

路由配置我们单独放在一个文件

router.ts

import {createWebHashHistory, createRouter} from 'vue-router'
import Home from './views/Home.vue'
import Doc from './views/Doc.vue'

const history = createWebHashHistory()
const router = createRouter({
  history: history,
  routes: [
    {path: '/', component: Home},
    {path: '/doc', component: Doc}
  ]
})
export default router

修改App.vue的内容

<template>
  <router-view />
</template>


<script lang="ts">
import { ref, provide } from "vue";
import router from "./router";

export default {
  name: "App",
  setup() {
    const width = document.documentElement.clientWidth;
    // 控制菜单显示
    const menuVisible = ref(width <= 500 ? false : true);
    provide("menuVisible", menuVisible); // set
    router.afterEach(() => {
      if (width <= 500) {
        menuVisible.value = false;
      }
    });
  },
};
</script>

全局样式index.css改为index.scss

index.scss

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
ul,
ol {
  list-style: none;
}
a {
  text-decoration: none;
  color: inherit;
  &:hover {
    border-bottom: 1px solid;
    cursor: pointer;
  }
}

h1,h2,h3,h4,h5,h6 {
  font-weight: normal;
}

body {
  font-size: 16px;
  // 为什么这样写 font-family
  // 答案见 https://github.com/zenozeng/fonts.css/
  font-family: -apple-system, "Noto Sans", "Helvetica Neue", Helvetica,
    "Nimbus Sans L", Arial, "Liberation Sans", "PingFang SC", "Hiragino Sans GB",
    "Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN",
    "Microsoft YaHei", "Wenquanyi Micro Hei", "WenQuanYi Zen Hei", "ST Heiti",
    SimHei, "WenQuanYi Zen Hei Sharp", sans-serif;
}

shime-vue.d.ts

这个文件用于解决提示找不到模块 xxx.vue的问题。

declare module '*.vue'{
    import {ComponentOptions} from 'vue'
    const componentOptions:ComponentOptions
    export default componentOptions
}

到这里,我们的官网框架基本搭建完成。

2. Switch组件开发

组件开发一般分为三步:

  • 需求分析
  • API设计
  • 写代码

2.1 需求分析

参考antd,element等组件库,我们的Switch组件大概长这样子,它的功能就是用来切换开关。

image.png

2.2 API设计

接下来我们来设计组件api,也就是别人要怎么用我们的组件。我们的switch组件接收一个value属性,这个属性可以是字符串,也可以是布尔值。

<Switch value="true" />       // value 为字符串 "true"
<Switch value="false" />      // value 为字符串 "false"
<Switch :value="  true  " />  // value 为布尔值 true
<Switch :value="  false  " /> // value 为布尔值 false

2.3 写代码

接下来,我们开始写代码,由于我们组件开发完后,需要有一个页面来展示,所以,我们要创建两个组件,一个是Switch组件,一个是SwitchDemo组件。

lib/Switch.vue

lib目录是用来存放我们组件库的组件目录。

<template>
  <button @click="toggle" :class="{ checked: value }"><span></span></button>
</template>

<script>
export default {
  props: {
    value: Boolean | String,
  },
  setup(props, context) {
    const toggle = () => {
      context.emit("update:value", !props.value);
    };
    return { toggle };
  },
};
</script>

<style lang="scss" scoped>
$h: 22px;
$h2: $h - 4px;
button {
  height: $h;
  width: $h * 2;
  border: none;
  background: #bfbfbf;
  border-radius: $h/2;
  position: relative;
  > span {
    position: absolute;
    top: 2px;
    left: 2px;
    height: $h2;
    width: $h2;
    background: white;
    border-radius: $h2 / 2;
    transition: all 250ms;
  }
  &.checked {
    background: #1890ff;
    > span {
      left: calc(100% - #{$h2} - 2px);
    }
  }
  &:focus {
    outline: none;
  }
  &:active {
    > span {
      width: $h2 + 4px;
    }
  }
  &.checked:active {
    > span {
      width: $h2 + 4px;
      margin-left: -4px;
    }
  }
}
</style>

components/SwitchDemo.vue

<template>
  <div>
    <Switch v-model:value="bool" />
  </div>
</template>
<script lang="ts">
import { ref } from "vue";
import Switch from "../lib/Switch.vue";
export default {
  components: { Switch },
  setup() {
    const bool = ref(false);
    return { bool };
  },
};
</script>

两个组件都开发完了,接下来需要配置路由让我们能访问到SwitchDemo这个页面

router.ts

...
import SwitchDemo from './components/SwitchDemo.vue'

const history = createWebHashHistory()
const router = createRouter({
  ...
    {
        path: '/doc', 
        component: Doc,
        redirect:'/doc/switch',
        children:[
          {path:'switch', component:SwitchDemo},
      ]
    }
  ]
})
export default router

打开文档页面,测试下我们开发的组件是否能正常工作。

image.png

点击switch按钮,确实可以工作,不过我们的demo看起来也太丑了吧,而且也没有源代码可以查看。接下来我们就来开发一个Demo组件,用于展示组件。

3. Demo组件开发

我们的思路是用一个switch.demo1.vue组件用于组件的演示,而Demo组件则是把显示传入的组件并且把组件的源代码显示出来。

新建switch.demo1.vue

<demo>常规用法</demo> 
<template>
  <Switch v-model:value="bool" />
</template>

<script lang="ts">
import { ref } from "vue";
import Switch from "../lib/Switch.vue";
export default {
  components: { Switch },
  setup() {
    const bool = ref(false);
    return { bool };
  },
};
</script>

这里的demo标签其实是一个标识符,用于区分其他组件,下面我们会讲如何获取组件的源代码

Demo.vue

<template>
  <div class="demo">
    <h2>{{ component.__sourceCodeTitle }}</h2>
    <div class="demo-component">
      <component :is="component" />
    </div>
    <div class="demo-actions">
      <div @click="hideCode" v-if="codeVisible">隐藏代码</div>
      <div @click="showCode" v-else>查看代码</div>
    </div>
    <div class="demo-code" v-if="codeVisible">
      <pre
        class="language-html"
        v-html="
          Prism.highlight(component.__sourceCode, Prism.languages.html, 'html')
        "
      />
    </div>
  </div>
</template>

<script lang="ts">
import "prismjs";
import "prismjs/themes/prism.css";
import { ref } from "vue";
const Prism = (window as any).Prism;

export default {
  props: {
    component: {
      type: Object,
      require: true,
    },
  },
  setup() {
    const codeVisible = ref(false);
    const showCode = () => (codeVisible.value = true);
    const hideCode = () => (codeVisible.value = false);
    return { Prism, codeVisible, showCode, hideCode };
  },
};
</script>

<style lang="scss">
$border-color: #d9d9d9;

.demo {
  border: 1px solid $border-color;
  border-radius: 6px;
  margin: 16px 0 32px;

  > h2 {
    font-size: 18px;
    padding: 8px 16px;
    border-bottom: 1px solid $border-color;
  }

  &-component {
    padding: 16px;
  }

  &-actions {
    padding: 8px 16px;
    border-top: 1px dashed $border-color;
  }

  &-code {
    padding: 8px 16px;
    border-top: 1px dashed $border-color;

    > pre {
      line-height: 1.1;
      font-family: Consolas, "Courier New", Courier, monospace;
      margin: 0;
    }
  }
}
</style>

这里用到了一个插件Prism,这个插件是用来高亮代码的。

安装依赖prismjs

yarn add prismjs

接下来我们重点讲下这个代码

 Prism.highlight(component.__sourceCode, Prism.languages.html, 'html')

你可能会好奇,这里的component.__sourceCode 这个属性是哪里来的,这其实是用到了vite的vueCustomBlockTransforms。

下面我们新建一个vite.config.ts,对vite进行配置。

vite.config.ts

// @ts-nocheck
import fs from 'fs'
import {baseParse} from '@vue/compiler-core'

export default {
  vueCustomBlockTransforms: {
    demo: (options) => {
      const { code, path } = options
      
      const file = fs.readFileSync(path).toString()
      // 对文件进行解析,找出有带demo标签
      const parsed = baseParse(file).children.find(n => n.tag === 'demo')
      // 获取demo标签的内容
      const title = parsed.children[0].content
      // 获取源代码的内容
      const main = file.split(parsed.loc.source).join('').trim()
      // 导出的时候给Component添加一个__sourceCode和__sourceCodeTitle属性
      return `export default function (Component) {
        Component.__sourceCode = ${
        JSON.stringify(main)
        }
        Component.__sourceCodeTitle = ${JSON.stringify(title)}
      }`.trim()
    }
  }
};

__sourceCode这个属性就是在这里添加进去的。

到这里,我们的Demo组件已经开发完成。

image.png

4. 打包和部署

接下来我们要把我们开发的组件库发布到npm,需要做两件事

  • 部署官网 让官网上线,有文档才会有人用你的轮子
  • 发布 gulu-ui 让其他开发者可以使用 npm install gulu-ui 安装源码

4.1 部署官网

其实就是把 dist 目录上传到网上,yarn build 时要注意设置 build path

发布官网的步骤
  • 如果有 dist 目录,则删除 dist 目录
  • 在 .gitignore 添加一行 /dist/ 然后提交代码
  • 运行 yarn build 创建出最新的 dist
  • 运行 hs dist 在本地测试网站是否成功运行
  • 部署到 GitHub
    • 运行命令
    • 开启 gulu-website 的 Pages 功能

这里我们利用github的Pages功能,需要在github创建一个仓库,然后开通Pages 功能,这方面网上有很多资料,我就不详细讲了。

接下来我们讲下如何一键部署。其实就是把我们的部署过程用bash脚本帮我们完成。

创建deploy.bash

cd dist
git init
git add .
git commit -m "first commit"
git branch -M master
git remote add origin 以git@开头的仓库地址
git push -f -u origin master 

4.2 打包库文件

官网已经搞定了,接下来打包库文件,vite 对此功能不支持,需要自行配置 rollup

第一步:创建 lib/index.ts

将所有需要导出的东西导出

export {default as Switch } from './Switch.vue'
第二步:rollup.config.js

告诉 rollup 怎么打包,需要安装对应的依赖,最好版本号跟我的一样,不然可能会出现各种问题。

import esbuild from 'rollup-plugin-esbuild'
import vue from 'rollup-plugin-vue'
import scss from 'rollup-plugin-scss'
import dartSass from 'sass';
import { terser } from "rollup-plugin-terser"

export default {
    input: 'src/lib/index.ts',
    output: [{
        globals: {
            vue: 'Vue'
        },
        name: 'Gulu',
        file: 'dist/lib/gulu.js',
        format: 'umd',
        plugins: [terser()]
    }, {

        name: 'Gulu',
        file: 'dist/lib/gulu.esm.js',
        format: 'es',
        plugins: [terser()]
    }],
    plugins: [
        scss({ include: /\.scss$/, sass: dartSass }),
        esbuild({
            include: /\.[jt]s$/,
            minify: process.env.NODE_ENV === 'production',
            target: 'es2015'
        }),
        vue({
            include: /\.vue$/,
        })
    ],
}
第三步:运行 rollup -c

请先全局安装 rollup(或者局部安装)

yarn global add rollup
npm i -g rollup
第四步:发布 dist/lib/ 目录

其实就是上传到 npm 的服务器

修改 package.json,添加 files 和 main

{
  "name": "gulu-ui-1",
  "version": "0.0.1",
  "files": ["dist/lib/*"],
  "main": "dist/lib/gulu.js",
  "module": "dist/lib/gulu.esm.js",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "resolutions": {
    "node-sass": "npm:sass@1.26.11"
  },
  "dependencies": {
    "github-markdown-css": "4.0.0",
    "marked": "1.1.1",
    "prismjs": "1.21.0",
    "vue": "3.0.0",
    "vue-router": "4.0.0-beta.3"
  },
  "devDependencies": {
    "@vue/compiler-sfc": "3.0.0",
    "rollup-plugin-esbuild": "2.5.0",
    "rollup-plugin-scss": "2.6.0",
    "rollup-plugin-terser": "7.0.2",
    "rollup-plugin-vue": "6.0.0-beta.10",
    "sass": "1.32.11",
    "vite": "1.0.0-rc.1"
  }
}

发布到npm

npm login
npm publish

请确保你没有在使用淘宝源,请使用官方源 npm config get registry npm config set registry registry.npmjs.org/

5. github地址

最后附上github地址,有兴趣的同学可以下载使用或者研究研究,demo有问题或者写的不好改进的地方也可以互相探讨下。如果有收获的话欢迎给个start,有意见也可以随时留言反馈