用 Vue 3+Vite+TypeScript 从0打造一个UI组件库

4,627 阅读4分钟

前言

最近在系统学习 Vue 3 ,想做一些轮子来提高自己开发的效率,就萌生了做一个UI组件库官网的想法。目前拥有InputSwitchDialogButtonTabs组件

预览链接(需要科学上网) 源代码链接(需要科学上网)

环境搭建和前期准备

安装

  • create-vite-app,可以直接使用 yarn dev 打开网页进行调试;
  • vue-router,用于配置不同页面的路由;
  • 其它有 github-markdown-cssmarkedsassprismjs

具体的版本号可以参考 package.json

{
  "name": "sky-ui-dev",
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "github-markdown-css": "4.0.0",
    "prismjs": "^1.21.0",
    "vue": "^3.1.3",
    "vue-router": "4.0.0-beta.3"
  },
  "devDependencies": {
    "@vue/compiler-sfc": "^3.1.3",
    "marked": "^2.1.1",
    "sass": "^1.32.11",
    "vite": "^1.0.0-rc.1"
  }
}

UI库的CSS最小影响原则

(1)不推荐使用scoped

HTML 标签中的 data-v-xxx 中的 xxx 每次运行都是随机数,我们需要输出稳定不变的 class 选择器,方便使用者覆盖。

(2)class前缀需要个性化

比如 .sky-theme-button,不太容易被使用者覆盖。.button.theme-link这类前缀就比较通用化,很容易被使用者覆盖。

(3)存放UI库的通用样式

我们可以新建一个 sky.scss ,用来存放UI库的通用样式,同时写名前缀仅用于此UI库。

[class^="sky-"], [class*=" sky-"] {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

[class^="sky-"] 意味着包含 tree- 开头的 class,相当于 <div class="tree-button"> 这类标签。

[class*=" sky-"] 意味着包含 空格+tree- 开头的 class,相当于 <div class="xxx sky-button">这类标签。

项目目录结构

plugins:存放项目介绍 markdown 文档展示的插件;

assets:官网logo;

components:不同页面的 Vue 文件;

lib:组件文件;

markdown:官网介绍和开始文档;

views:官网的页面文件。

Vue Router 配置

在项目根目录下创建 router.ts 文件,我们这里使用 hash 模式来配置路由,不需要架设后台服务器。

// hash 模式
const history = createWebHashHistory();
export const router = createRouter({
    history: history,
  	// 链接到不同的文件
    routes: [
        {path: '/', component: Home},
        {
            path: '/doc',
            component: Doc,
            children: [
                {path: '', redirect: '/doc/intro'},
                {path: 'intro', component: md(intro)},
                {path: 'get-started', component: md(getStarted)},
                {path: 'switch', component: SwitchDemo},
                {path: 'button', component: ButtonDemo},
                {path: 'dialog', component: DialogDemo},
                {path: 'tabs', component: TabsDemo},
                {path: 'radio', component: RadioDemo},
                {path: 'input', component: InputDemo}
            ]
        }
    ]
});
router.afterEach(() => {});

App.vue 加入 <router-view>

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

<script lang="ts">
import {router} from './router';

export default {
  name: 'App',
  setup(){}
}
</script>

main.ts 中加入:

app.use(router)

到此,一个项目的前期工作已经基本准备完毕,接下来就开始我们的UI库开发。

组件开发

在开发过程中细节比较多,这里就挑几个有代表性的出来讲讲。

如何让组件的某一部分触发事件?

Button 组件中,外部有一个 div 红框,点击这个 div 会触发事件,但我们只需要点击 button 触发事件。如何才能实现呢?

第一步:让组件里的 div 不继承属性

设置 inheritAttrs: false 即可,这时点击div不会触发事件。inherit意为继承,attrs为attributes的缩写,意为属性。但是,这时的button点击也不会触发事件,因为这个语句默认会把所有继承的事件屏蔽掉。下一步我们需要进行另外的设置。

<script lang="ts">
export default {
  inheritAttrs: false
}
</script>

第二步:让 div 中的 button 绑定 $attrs

$attrs 可以让某个元素 button 继承绑定在 Button 上的所有属性。只需要在 button 上加一句 v-bind="$attrs"即可。

<button v-bind="$attrs">

这时点击 button,就可以在控制台中看到 @click/@focus/@mouseover 事件结果出现。

如何按需加载不同组件的事件?

比如我一个组件想要 clickfocus 两个事件,另一个组件只需要 click 事件,应该如何实现呢?主要思路是在组件内部声明setup,传入参数,并将获取到的参数 return 出来即可。

<template>
  <div :size="size"> <!--第三步:在标签上定义获取到的事件或属性,这里是规定了尺寸大小-->
    <button v-bind="$attrs">
      <slot/>
    </button>
  </div>
</template>

<script lang="ts">
export default {
  inheritAttrs: false,
  setup(props, context){
    //第一步:析构语法获取到外部的事件或属性
    const {size, onClick, onMouseOver} = context.attrs;
    //第二步:把获取到的size事件或属性return出来
    return {size}
  }
}
</script>

还可以使用 ES6 最新的剩余操作符来简化。获取到 ...rest 之后,在 template 中使用即可。

<script lang="ts">
export default {
  inheritAttrs: false,
  setup(props, context){
    //使用...rest扩展操作符,获取到除size之外其他的属性或事件
    const {size, ...rest} = context.attrs;
    return {size, rest}
  }
}
</script>

弹窗中的具名插槽(slot)

我们制作的弹窗(Dialog),希望可以让用户自定义其中的标题和内容,这时候可以使用具名插槽 v-slot。Vue 3 中的具名插槽的使用方式跟 Vue 2 有所不同。

第一步:使用 v-slot:xxx 定义好插槽的名字:

<template v-slot:content>
   <div>hello</div>
</template>

第二步:在需要的地方引入插槽,使用 <slot name="xxx">

<main>
    <slot name="content"/>
</main>

实现效果如下:

使用 Teleport “传送”组件

由于 CSS 层级上下文的原因,Dialog 组件虽然 z-index10,但是其处于<div style="z-index: 1;">标签中,所以 Dialog 的层级肯定是没有外部 <div style="z-index: 2;"> 的层级高,从而被红色方框遮挡。这个组件的层级是由其所在的环境大小决定的,而不是由组件本身的层级来决定。 打个比方,一般学校里的尖子班,即便成绩排名中游,也会比普通班的中游成绩要高。

那我们应该如何防止 Dialog 被遮挡呢?这时候就需要使用 teleportteleport 的中文意思是“传送”,我们可以借用它来把 Dialog “传送”出去,这样就不会被其他元素影响了。

使用 <Teleport>把需要“传送”的组件包起来,加入 to="body" 来指定需要传送的目的地,这里直接移动到 body 标签下。

成功后,我们在开发者工具中就可以看到 Dialog 已经被成功“传送”了。

动态挂载组件

如果不想在组件中声明一个变量,又想改变这个变量的值,我们可以使用 createApp 的 h 来实现。这里就拿【一键打开Dialog组件】这个功能来举例。

第一步:创建一个 Button,添加 @click="showDialog" 事件,功能为点击打开 Dialog

第二步:showDialog 函数中调用 openDialog,用户可以传入 titlecontentokcancel 来自定义 Dialog 组件中的内容;

<script>
    const showDialog = ()=>{
      openDialog({
        title:'标题',
        content:'你好',
        ok(){console.log('ok');},
        cancel(){console.log('cancel');}
      })
    }
    return {showDialog}
  }
}
</script>

第三步:创建一个 openDialog.ts 组件,可以获取外部的属性,在 body 中直接创建一个 div 。这里需要使用h() 来构造新的 Dialog

import Dialog from './Dialog.vue';
import {createApp, h} from 'vue';

export const openDialog = (options) => {
    const {title, content, ok, cancel} = options;
    const div = document.createElement('div');
    document.body.appendChild(div);
    const close = () => {
        app.unmount(div);
        div.remove();
    };
    const app = createApp({
        render() {
          //使用h构造Dialog
            return h(Dialog, {
                visible: true, 'onUpdate:visible': (newVisible) => {
                    if (newVisible === false) {
                        close();
                    }
                },
                ok, cancel
            }, {
                title, content
            });
        }
    });
  //挂载新的div
    app.mount(div);
};

使用 Template Refs 动态设置 div 宽度

在做好 Tab 的底部导航条提醒后,一开始把宽度规定为 100px,但宽度已经超过了文字的宽度。对于组件库来说,如何根据使用者的文字来动态设置这个导航条的宽度呢?我们需要使用 Vue3 中的 Template Refs

根据 Vue 3 文档中的用法,我们在导航文字 div 上绑定一个 ref ,挂载组件的时候使用 ref 里面的 value 获取到 div 宽度。

<template>
  <div :ref="element => { if(element) navItems[index] = element }"></div>
<!--如果element存在,那么就让navItems里的元素等于element-->
  <div class="tree-tabs-nav-indicator"></div>
</template>

<script lang="ts">
import {onMounted, ref} from 'vue';

export default {
  setup(props, context) {
    const navItems = ref<HTMLDivElement[]>([]);
    const indicator = ref<HTMLDivElement>(null);
    
    onMounted(() => {
      const divs = navItems.value;
      const result = divs.filter(div=>div.classList.contains('selected'))[0]
      const {width} = result.getBoundingClientRect()
      indicator.value.style.width = width + 'px'
    });
    
    return {navItems, indicator};
  }
};
</script>

成功运行代码后,我们可以在控制台实时获取 indicator 宽度。

组件源代码美化

使用 custom block 展示源代码

我们想要把组件中的源代码展示到页面上,这时候需要用到custom block这个插件。

第一步:在 vite.config.ts 中加入以下代码,主要作用是解析组件中的代码。

import {md} from './plugins/md'
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()
            const parsed = baseParse(file).children.find(n => n.tag === 'demo')
            const title = parsed.children[0].content
            const main = file.split(parsed.loc.source).join('').trim()
            return `export default function (Component) {
        Component.__sourceCode = ${
                JSON.stringify(main)
            }
        Component.__sourceCodeTitle = ${JSON.stringify(title)}
      }`.trim()
        }
    }
}

第二步:在需要展示代码的组件中,添加 <demo> 标签:

<demo>
常规用法
</demo>

<template>
  <Switch v-model:value="bool"/>
</template>

第三步:父组件中展示代码:

<div class="demo-code">
  <pre>{{Switch1Demo.__sourceCode}}</pre>
</div>

使用 prismjsv-html 高亮源代码

prismjs 是一个用于高亮代码的库,只需引入即可使用,适用于我们UI库中进行代码展示。

安装prismjs:yarn add prismjs

在组件中引入,并使用 setup() 导出,方便使用:

import 'prismjs';
import 'prismjs/themes/prism-okaidia.css';
//themes文件中为不同的主题,大家可以多试几个,用自己比较喜欢的

const Prism = (window as any).Prism;
export default {
  setup() {
    return {Prism};
  }
};

template 中进行展示:

<template>
   <div class="demo-code">
     <pre class="language-html" v-html="Prism.highlight(Switch1Demo.__sourceCode,
            Prism.languages.html,'html')"/>
   </div>
</template>

高亮代码效果如下:

兼容 TypeScript 和 markdown

有时候一些库没有TypeScript声明文件,可以在安装完库后运行如下代码,xxx为库名:

yarn add --dev @types/xxx

一般IDE都不会识别markdown类型文件,可以在 shims.d.ts 中声明一下,避免报错:

declare module '*.md'{
    const str: string
    export default str
}

项目自动化打包和部署

使用如下命令:

yarn build

运行成功后会在项目根目录下生成一个 dist 文件夹,只需要把这个文件夹上传到 github 即可。

一般从打包到部署 github 需要输入很多命令行,这时候我们可以使用一个 deploy.sh 来实现自动化部署。

在项目根目录下新建一个 deploy.sh,输入以下命令(加&&是需要确认命令的运行情况,如某条命令运行失败则步停止):

rm -rf dist &&
yarn build &&
cd dist &&
git init &&
git add . &&
git commit -m "update" &&
git branch -M main &&
//这里输入你的的github仓库地址 
git remote add origin git@github.com:xxxxxx &&
git push -f -u origin main &&
cd ..

以后只需要在终端中输入 sh deploy.sh 即可实现一行代码部署。

如果这篇文章帮助到了你,欢迎关注,点赞,评论~