vue3+ts+vite组件库搭建

879 阅读5分钟

记录一次UI组件库搭建过程,涉及到的技术很多,也遇到很多问题,大致工程参考Element-plus仓库搭建。其中关键技术点和遇到的问题,大量借鉴各社区大佬文章及解决方案,最终得以实现,站在巨人肩膀上,致敬,学习。

下面内容,你可以跳过直接去github查看源码,如果对你有帮助,希望start一下 谢谢! github:github地址

搭建组件库-环境包管理

搭建monorepo环境

我们使用pnpm当做包管理工具,用pnpm workspace来实现monorepo

当使用 npm 或 Yarn 时,如果你有 100 个项目使用了某个依赖(dependency),就会有 100 份该依赖的副本保存在硬盘上。  而在使用 pnpm 时,依赖会被存储在内容可寻址的存储中,所以:

  1. 如果你用到了某依赖项的不同版本,只会将不同版本间有差异的文件添加到仓库。 例如,如果某个包有100个文件,而它的新版本只改变了其中1个文件。那么 pnpm update 时只会向存储中心额外添加1个新文件,而不会因为仅仅一个文件的改变复制整新版本包的内容。
  2. 所有文件都会存储在硬盘上的某一位置。 当软件包被被安装时,包里的文件会硬链接到这一位置,而不会占用额外的磁盘空间。 这允许你跨项目地共享同一版本的依赖。

因此,您在磁盘上节省了大量空间,这与项目和依赖项的数量成正比,并且安装速度要快得多! 详细了解点击这里查看

不多bobo开整

首先需要全局安装pnpm

npm install pnpm -g // 全局安装pnpm

在你的桌面新增一个文件夹手动或者复制代码

mkdir xlz-ui  //创建项目文件
cd xlz-ui  //进入目录
pnpm init  //初始化package.json配置⽂件 私有库

修改package.json删除掉无用配置

{
  "private": true,
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "typescript": "^4.8.4",
    "vue": "^3.2.41"
  }
}

安装vue3typescript依赖

pnpm install vue@next typescript -D // 全局下添加依赖

创建 .npmrc

touch .npmrc

.npmrc内容添加 .npmrc配置更多详情

shamefully-hoist = true  // 作用依赖包都扁平化的安装在node_modules下面

创建tsconfig.json文件

touch tsconfig.json //创建tsconfig.json
npx tsc --init // 初始化ts配置文件

配置如下 如果需要了解全部配置请看这里

{
  "compilerOptions": {
    "module": "ESNext", // 打包模块类型ESNext
    "declaration": false, // 默认不要声明⽂件
    "noImplicitAny": false, // ⽀持类型不标注可以默认any
    "removeComments": true, // 删除注释
    "moduleResolution": "node", // 按照node模块来解析
    "esModuleInterop": true, // ⽀持es6,commonjs模块
    "jsx": "preserve", // jsx 不转
    "noLib": false, // 不处理类库
    "target": "es6", // 遵循es6版本
    "sourceMap": true,
    "lib": [
      // 编译时⽤的库
      "ESNext",
      "DOM"
    ],
    "allowSyntheticDefaultImports": true, // 允许没有导出的模块中导⼊
    "experimentalDecorators": true, // 装饰器语法
    "forceConsistentCasingInFileNames": true, // 强制区分⼤⼩写
    "resolveJsonModule": true, // 解析json模块
    "strict": true, // 是否启动严格模式
    "skipLibCheck": true, // 跳过类库检测
    "types": ["unplugin-vue-define-options"] // sfc 添加 name属性的包需要的
  },
  "exclude": [
    // 排除掉哪些类库
    "node_modules",
    "**/__tests__",
    "dist/**"
  ]
}

在项目根目录下面创建pnpm-workspace.yaml配置文件。

touch pnpm-workspace.yaml

配置如下

packages:
  - "packages/**" # 存放所有组件
  - docs # 文档
  - play # 测试组件

pnpm-workspace.yaml 定义了 工作空间 的根目录,并能够使您从工作空间中包含 / 排除目录 。 默认情况下,包含所有子目录。

创建组件测试环境

pnpm create vite play --template vue-ts
cd play 
pnpm install

在根目录新建一个typings目录,用来存放项目中通用的自定义的类型,然后把用vite创建的play/src下面的vite-env.d.ts移动到typings下面去。

在根目录下面的package.json下面添加scripts脚本。pnpm -C <path>, --dir <path>在 <path> 中启动 pnpm ,而不是当前的工作目录。

  "scripts": {
    "dev": "pnpm -C play dev" 
  }

这样就可以在根目录执行pnpm dev启动测试服务了

创建组件目录结构

+ packages //跟目录中创建
    - components  // 组件代码
    - theme-chalk // 样式
    - utils  // 公共方法

依次创建并初始化pnpm init修改package.json

# 以components为例子,其它同,修改name即可
{
  "name": "@xlz-ui/components",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

在根目录下安装三个子包pnpm install @xlz-ui/components @xlz-ui/theme-chalk @xlz-ui/utils -w,其它两个包同样的操作,-w--workspace代表允许安装到根目录下,不加会报错、执行-w 的命令可以在任意目录下执行都会安装在根目录,然后查看根目录下的package.json已经有了这三个包

//package.json中被添加了三个包
"dependencies": {
    "@xlz-ui/components": "workspace:^1.0.0",
    "@xlz-ui/theme-chalk": "workspace:^1.0.0",
    "@xlz-ui/utils": "workspace:^1.0.0"
  }

components目录下创建icon目录,来编写一个icon组件,目录如下:

    +components
        + icon
            + src # 组件源代码
                - icon.ts  # 放组件的props及公共方法
                - icon.vue # 组件代码
            - index.ts # 组件入口
        + index.ts //组件整体抛出 后续为了全部导入做准备

icon.ts下来定义props

import { ExtractPropTypes } from 'vue';
// 定义props类型声明
export const iconProps = {
  name: {
    type: String,
  },
  size: {
    type: [Number,String],
  },
  color: {
    type: String,
  },
} as const
//as const,会让对象的每个属性变成只读(readonly)
export type IconProps = ExtractPropTypes<typeof iconProps>;

icon.vue中写组件代码

<template>
  <svg :class="bem.b()" :style="style" aria-hidden="true">
    <use :xlink:href="iconName"></use>
  </svg>
</template>
<script lang="ts" setup>
import { computed, CSSProperties } from "vue";
import "./font/iconfont.js";  //这里用了阿里适量
import { createNamespace } from "@xlz-ui/utils";
import { iconProps } from "./icon";
const bem = createNamespace("icon");
defineOptions({
  name: "XIcon",
});
const props = defineProps(iconProps);
const iconName = computed(() => {
  return `#xlz-${props?.name}`;
});
const style = computed<CSSProperties>(() => {
  const { size, color } = props;
  if (!color && !size) {
    return {};
  }
  return {
    ...(size ? { "font-size": size + "px" } : {}),
    ...(color ? { color: color } : {}),
  };
});
</script>

在组件入口处导出组件,index.ts

import _Icon from './src/icon.vue';
import { withInstall } from '@xlz-ui/utils';
const XIcon = withInstall(_Icon); // 生成带有 install 方法的组件
export {//提供按需加载
  XIcon
}
export default XIcon; // 导出组件

在icon同级的index.ts导出icon

export  * from './icon'

接下来解决上面用的withInstallcreateNamespace方法

css-BEM命名规范

具体规则这里推荐一篇文章提供参考我也是看的这篇

Js 实现部分utils/src/create.ts中写一几个方法

/**
 *
 * @param prefixName 前缀名
 * @param blockName 代码块名
 * @param elementName 元素名
 * @param modifierName 装饰符名
 * @returns  说白了 ,就是提供一个函数,用来拼接三个字符串,并用不同的符号进行分隔开来
 */
 function _bem(prefixName, blockName, elementName, modifierName) {
  if (blockName) {
    prefixName += `-${blockName}`;
  }
  if (elementName) {
    prefixName += `__${elementName}`;
  }
  if (modifierName) {
    prefixName += `--${modifierName}`;
  }
  return prefixName;
}

/**
 *
 * @param prefixName 前缀
 * @returns
 */
function createBEM(prefixName: string) {
  const b = (blockName?) => _bem(prefixName, blockName, "", "");

  const e = (elementName) =>
    elementName ? _bem(prefixName, "", elementName, "") : "";

  const m = (modifierName) =>
    modifierName ? _bem(prefixName, "", "", modifierName) : "";

  const be = (blockName, elementName) =>
    blockName && elementName
      ? _bem(prefixName, blockName, elementName, "")
      : "";
  const bm = (blockName, modifierName) =>
    blockName && modifierName
      ? _bem(prefixName, blockName, "", modifierName)
      : "";
  const em = (elementName, modifierName) =>
    elementName && modifierName
      ? _bem(prefixName, "", elementName, modifierName)
      : "";
  const bem = (blockName, elementName, modifierName) =>
    blockName && elementName && modifierName
      ? _bem(prefixName, blockName, elementName, modifierName)
      : "";
  const is = (name, state?) => (state ? `is-${name}` : "");
  return {
    b,
    e,
    m,
    be,
    bm,
    em,
    bem,
    is,
  };
}
export function createNamespace(name: string) {
  const prefixName = `xlz-${name}`;
  return createBEM(prefixName);
}

Bem scss 部分 根据下方创建文件

theme-chalk
├── package.json
└── src
   ├── icon.scss
   ├── index.scss
   ├── mixins
   │   ├── config.scss
   │   └── mixins.scss

config.scss

$namespace: "xlz";
$element-separator: "__"; // 元素连接符
$modifier-separator: "--"; // 修饰符连接符
$state-prefix: "is-"; // 状态连接符
* {
 box-sizing: border-box;
}

mixins.scss

@use "config" as *;
@forward "config";

// xlz-icon
@mixin b($block) {
 $B: $namespace + "-" + $block;
 .#{$B} {
   @content;
 }
}

// xlz-icon.is-xxx
@mixin when($state) {
 @at-root {
   &.#{$state-prefix + $state} {
     @content;
   }
 }
}

// .xlz-icon--primary
@mixin m($modifier) {
 @at-root {
   #{& + $modifier-separator + $modifier} {
     @content;
   }
 }
}

// xlz-icon__header
@mixin e($element) {
 @at-root {
   #{& + $element-separator + $element} {
     @content;
   }
 }
}

index.scss

@use './icon.scss';

icon.scss

@use './mixins/mixins.scss' as *;
@keyframes transform {
 from {
   transform: rotate(0deg);
 }
 to {
   transform: rotate(360deg);
 }
}
@include b(icon) {
 width: 1em;
 height: 1em;
 line-height: 1em;
 display: inline-flex;
 vertical-align: middle;
 svg.loading {
   animation: transform 1s linear infinite;
 }
}

withInstall方法

utils/src/with-install.ts文件,代码如下:

import type { App, Plugin } from "vue"; // 只是导入类型不是导入App的值

/**
* 组件外部使用use时执行install,然后将组件注册为全局
*/

// 类型必须导出否则生成不了.d.ts文件
export type SFCWithInstall<T> = T & Plugin;

/**
 * 定义一个withInstall方法处理以下组件类型问题
 * @param comp 
 */
export const withInstall = <T>(comp: T) => {
  /**
   * 直接写comp.install = function(){} 的话会报错,因为comp下没有install方法
   * 所以从vue中引入Plugin类型,断言comp的类型为T&Plugin
   */
  (comp as SFCWithInstall<T>).install = function (app: App) {
    app.component((comp as any).name, comp);
  };
  return comp as SFCWithInstall<T>;
};

utils/index.ts中添加

export * from './src/create'
export * from './src/with-install'

icon使用阿里适量字体库搭建

登录自己的iconfont账号没有的注册一个

image.png

image.png 购物车中随便添加几个icon image.png 添加到新建的项目中 image.png

image.png 下载后之需要将iconfont.js放在icon/src/font中并把icon引入play/src/app.vue中执行pnpm dev起服务

<template>
  <XIcon name="anquanchaxun" color="red"></XIcon>
</template>
<script lang="ts" setup>
import "@xlz-ui/theme-chalk/src/icon.scss";
import XIcon from "@xlz-ui/components/icon";
</script>

不出意外控制台会抱一个defineOptions is not defined咱们来解决一下 咱们在play中安装一下unplugin-vue-define-options

pnpm i unplugin-vue-define-options -D

配置vite.config.ts

import { defineConfig } from 'vite'
import DefineOptions from 'unplugin-vue-define-options/vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue(), DefineOptions()],
})

前面忘记了安装sass现在补一下 安装在根目录 然后起服务

pnpm i sass -w -D

不出意外你会看到一个icon

image.png

想法

  1. icon 其实还有其他的方式字体的方式引入字体文件 然后用class的方式展示icon 咱们这里是用的svg形式 没有想好最用用那种方式暂时用这个
  2. 组件的按需加载与全部导入方式
  3. 组件的打包方式gulp+rollup
  4. git提交规范的设计
  5. 代码规范的设计

到此结束 欢迎一起沟通交流 欢迎大神指点✌️🫡