学习制作一个基于Vue3 + TS 的UI库
吃一堑长一智
先说明一下我写这篇博客的原因,因为我在学习过程中,自己胡乱鼓捣导致yarn build
命令报错,自己完全菜鸡把我写的代码目录重新删掉,从GitHub上下载回来,尝试正常以后我本想重新git上去,但是报错,我在谷歌搜索解决办法,使用git push -u origin main
导致我的提交记录全都删掉,我跟着学的八十几commit全都不翼而飞😱就很崩溃,就写下这篇文章,记录一下思路,把脉络梳理,方便日后重新回顾,水平慢慢提高
[项目代码](zxjdzxb/ShellyShip-UI: 预览效果 (github.com))
[预览效果]ShellyShip UI库 (zxjdzxb.github.io))
1、使用Vite项目搭建
这次我们使用Vite搭建:
- 先全局安装create-vite-app:yarn global add create-vite-app,
- 然后cva 项目名, 或者yarn create vite-app
- cd
- yarn
- yarn dev
2、使用 Vue Router4 用于页面切换
- 先安装,yarn add vue-router
- 初始化
- 新建 history 对象
- 新建 router 对象
- 引入 TypeScript
把main.js改为main.ts //main.ts import {createWebHashHistory, createRouter} from 'vue-router' const history = createWebHashHistory() const router = createRouter({ history, routes: [ { path: '/', component: Lifa } ] })
- app.use(router)
- 添加
- 添加
.vue文件会报错,解决办法是:
创建src/shims.d.ts,告诉TS如何理解.vue文件:
declare module '*.vue' { import { ComponentOptions } from 'vue' const componentOptions: ComponentOptions export default componentOptions }
3、开始创建页面
这里大概是分两个页面,一个Home和一个Doc,路径为 #/ 时渲染Home,#/doc渲染Doc
Home.vue
- Topnav:左边是logo,右边是menu
- Banner:文字介绍 + 开始按钮
Doc.vue
- Topnav:同上
- Content:左边是 aside,右边是 main
4、项目制作
将一些页面共有代码放进去,让自己更深入的理解,并且更有逻辑性,也使自己更容易修改 添加样式,vue的实现都很方便,大家赶兴趣的可以看一下我的源码
写代码的步骤和思路
- 写 HTML
- 写 CSS
- 写 JS
- 测试
- 改写代码
- 再测试
- 再改写
- 再测试
- 再改写
- 再测试
- 再改写
如何让组件的某一部分触发事件?
在 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
事件结果出现。
如何按需加载不同组件的事件?
比如我一个组件想要 click
和 focus
两个事件,另一个组件只需要 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-index
为10
,但是其处于<div style="z-index: 1;">
标签中,所以 Dialog 的层级肯定是没有外部 <div style="z-index: 2;">
的层级高,从而被红色方框遮挡。这个组件的层级是由其所在的环境大小决定的,而不是由组件本身的层级来决定。 打个比方,一般学校里的尖子班,即便成绩排名中游,也会比普通班的中游成绩要高。
那我们应该如何防止 Dialog
被遮挡呢?这时候就需要使用 teleport
。teleport
的中文意思是“传送”,我们可以借用它来把 Dialog
“传送”出去,这样就不会被其他元素影响了。
使用 <Teleport>
把需要“传送”的组件包起来,加入 to="body"
来指定需要传送的目的地,这里直接移动到 body
标签下。
成功后,我们在开发者工具中就可以看到 Dialog
已经被成功“传送”了。
动态挂载组件
如果不想在组件中声明一个变量,又想改变这个变量的值,我们可以使用 createApp 的 h 来实现。这里就拿【一键打开Dialog组件】这个功能来举例。
第一步:创建一个 Button
,添加 @click="showDialog"
事件,功能为点击打开 Dialog
;
第二步:showDialog
函数中调用 openDialog
,用户可以传入 title
、content
、ok
、cancel
来自定义 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>
使用 prismjs
和 v-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
即可实现一行代码部署
参考
[大威Wayne](juejin.cn/post/698756…
5、关于Vue3的知识点
1. ref, 接受一个内部值并返回一个响应式且可变的 ref 对象。ref 对象具有指向内部值的单个 property.value
。
import {ref} from 'vue' const value = ref<boolean>(false) //false为默认值 return { value }
2.setup(compoents ApI)
export default{ /* props 父组件传过来的属性 context当前实例 */ setup(props,context){ //this.$emit('xxx') context.emit('xxxx') } }
3.v-model
<switch :value='x' @update:value="x=$event"/> <switch v-model:value="y"/>
4.inheritAttrs:false(组件内部不在继承调用时传来的属性)
默认所有属性都绑定到了根元素之上
<script lang="ts"> export default { inheritAttrs:false } </script>
5.$attrs(组价调用时候传递过来的属性对象)
6.props vs attrs 的区别
- props需要先声明才能取值,attrs不用先声明
- props不包含事件,attrs包含
- props支持string以外的类型,attrs只有string类型(这条貌似存疑, attrs好像也能接受其他)
- props没有声明的属性,会跑到attrs里面去
7.ui库不能使用scoped ,每一个class 必须添加前缀,css最小影响原则
8.检查子组件传来的参数,是不是一个组件。
每一个.vue文件都将转换成type 而这个type 就是一个render函数
9.组件内部用JS获取slot传来的内容(context.slots.default())
10.v-for 获取每一个el元素
内部用途 v-for
在 Vue 2 中,在v-for
里使用的ref
attribute 会用 ref 数组填充相应的$refs
property;
在 Vue 3 中,这样的用法将不再在$ref
中自动创建数组。要从单个绑定获取多个 ref,请将ref
绑定到一个更灵活的函数上 (这是一个新特性)
在内部使用时,Composition API模板引用没有特殊处理v-for
。而是使用函数ref来执行自定义处理:
<template> <div v-for="(item, i) in list" :ref="el => { if (el) divs[i] = el }"> {{ item }} </div> </template> <script> import { ref, reactive, onBeforeUpdate } from 'vue' export default { setup() { const list = reactive([1, 2, 3]) const divs = ref([]) // make sure to reset the refs before each update onBeforeUpdate(() => { divs.value = [] }) return { list, divs } } } </script>
11.组件触发事件之后要满足一定的条件再执行某个动作的时候就不能使用emit 触发事件,必须使用函数当做props,然后在内部进行判断。
12.钩子onMounted和onUpdated,可考虑使用watchEffect代替
13.Teleport 提供了一种干净的方法,允许我们控制在 DOM 中哪个父节点下呈现 HTML,而不必求助于全局状态或将其拆分为两个组件。
<Teleport to="body"> 内容 </Teleport> ```