从0开始搭建一个包含文档的组件库

544 阅读8分钟

初始化项目

使用vue-press作为文档框架,使用vue3+ts+vite作为组件库和测试项目框架,Pnpm Workspaces 作为 Monorepo 项目的依赖管理工具

新建一个文件夹,根据vue-press的官方文档,在根目录下安装了vue-press,同时使用vite初始化组件库项目。

修改组件库项目package.json

修改name字段为组件库名字 修改入口文件字段mian为组件库项目入口文件。

// package.json部分配置
{
  "name": "test-ui",
  "private": true,
  "version": "1.0.0",
  "type": "module", // 指定当前包的模块类型。当设置为 "module" 时,Node.js 会将所有 .js 文件视为 ES 模块(ESM),而不是 CommonJS 模块(CJS)。
  "files": [
    "dist" // 指定发布到 npm 时,包含在包中的文件和目录。
  ],
  "main": "./main.ts", // 定义该包的入口文件,用于 CommonJS 规范。项目中有代码使用 require('your-package-name') 引入此包时,会加载 main 字段指定的文件。
  "types": "./main.ts", // 指定 TypeScript 的类型声明文件路径。告诉 TypeScript 编译器在使用该包时引用的 .d.ts 文件位置,以便在使用此包时提供类型提示。ps: 针对打包后的文件地址,开发环境可以不指定。
  "module": "./main.ts" // 定义该包的 ES Module 入口文件。项目中使用 import 语法引入此包(如 import something from 'your-package-name'),会加载 module 字段指定的文件。
}

配置workspace

根目录新建一个 pnpm-workspace.yaml,将组件库目录作为包进行管理,该文件里的项目都可以被共享

packages:
  # all packages in direct subdirs of packages/
  - 'lib/*'

需要注意的是:

在任意一个目录下,安装库时,如果不添加--filter参数,会安装工作区根目录下,并且就算其它项目没有在package.json里指定使用这个库,也能正常使用。

组件库配置

全局导入

建立一个Components文件夹用来存放组件,main.ts里导出组件,需要提供一个install方法,以便于使用项目全局注册 使用app.copmponent方法全局注册组件。

import './style.css'
import YButton from './components/YButton/install'
import YInput from './components/YInput/install'
import { App } from 'vue'const components = [
  { name: 'YButton', component: YButton },
  { name: 'YInput', component: YInput },
]
const install = (app: App): void => {
  components.forEach((component) => {
    app.component(component.name, component.component)
  })
}
export default { install }

类型声明 (开发模式下)

如果希望组件库能有类型声明,我这里是使用了unplugin-vue-components帮我自动生成了全局组件的类型文件components.d.ts

/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
​
/* prettier-ignore */
declare module 'vue' {
  export interface GlobalComponents {
    YButton: typeof import('./components/YButton/index.vue')['default']
    YInput: typeof import('./components/YInput/index.vue')['default']
  }
}

需要在vite.config.ts里写上:

import Components from 'unplugin-vue-components/vite'export default defineConfig({
  plugins: [
    Components({
      dts: true,
    })
  ]
})

并且希望在工作区中组件库的类型声明正常的话,需要在使用项目中的tsconfig.json中,为types字段添加属性:

compilerOptions: {
  "types": ["test-ui/components.d.ts"] // 或者使用相对路径也是可以的
}

类型声明(生产模式下)待验证

这里使用vite-plugin-dts插件来生成类型文件

import dts from 'vite-plugin-dts'export default defineConfig({
  plugins: [
    dts({
      outDir: 'dist', // 输出目录
      insertTypesEntry: true, // 自动插入 types 入口
      tsconfigPath: './tsconfig.json', // 如果是vite生成的项目,需要指明为./tsconfig.app.json
    }),
  ]
})

并且package.json中的types字段需要指明为dist下的路径。会生成一个总的.d.ts文件以及根据目录也会生成一个目录级的type文件

按需加载

按需引用目前是依赖于esm的treeshaking。所以想要支持按需引入那么必然使用的是module入口,module入口它是个统一的入口,这个文件中显然导出了所有组件,那么比如我们只导出Button组件,其他没有用到的组件最终是不是不会被打包进去呢,实际上并没有这么简单,因为有的文件它会存在副作用,比如修改了原型链、设置了全局变量等,所以虽然没有显式的被使用,但是只要引入了该文件,副作用就生效了,所以不能被删除,要解决这个问题需要在package.json中再配置一个sideEffects字段,指明哪些文件是存在副作用的,没有指明的就是没有副作用的,那么构建工具就可以放心的删除了。

目前我的vite.config.ts中的构建配置为:

build: {
    outDir: './dist',
    lib: {
      entry: resolve(__dirname, './src/main.ts'),
      name: 'test-ui',
      fileName: 'test-ui',
      formats: ['es', 'cjs'], // 输出不同格式
    },
  }

这样打出来的包会生成两种格式:js和cjs,而且是一个大的文件。我们需要按需加载肯定不能只要一个大文件。这个时候,得重新改下打包的配置:

build: {
    outDir: './dist',
    lib: {
      entry: resolve(__dirname, './src/main.ts'),
      name: 'test-ui',
      fileName: 'test-ui',
    },
    rollupOptions: {
      external: ['vue'],
      output: [
        {
          format: 'es', // ES Module 格式
          dir: 'dist/es', // 输出目录
          preserveModules: true, // 保持模块独立
          entryFileNames: '[name].mjs',
        },
        {
          format: 'cjs', // CJS 格式
          dir: 'dist/cjs', // 输出到 dist/cjs 目录
          preserveModules: true, // 保持模块独立
          entryFileNames: '[name].cjs', // 指定输出文件名称
        },
      ],
    },
  }

如果想要测试是否实现了按需加载,在开发环境下是无法测试的,为了模拟真实的场景,我们需要构建后测试。此时,我们使用两款工具来进行测试:yalcrollup-plugin-visualizer

yalc

对标npm/yarn link,但是模拟的是真实发布的场景,不会存在link中存在的软链接和文件系统引发的其他各种奇怪的问题,以及node_modules中原本的组件库包和全局的组件库包。

  1. 全局安装:pnpm i yalc -g
  2. 构建发布产物:pnpm build
  3. 发布:进入到需要发布的组件库里,执行:yalc publish
  4. 进入到使用的项目:yalc add test-ui ,当前项目会在package.json中添加:"test-ui": "file:.yalc/test-ui",
  5. 如果需要从使用项目中删除该组件库,执行:yalc remove test-ui
  6. 更新和推送:yalc publish --push 可以简写为: yalc push,最新的包直接在你使用的项目中生效,而且还能实现hrm
  7. 从本地商店中删除组件库:yalc installations clean my-package

rollup-plugin-visualizer

可视化并分析Rollup包,以查看哪些模块占用了空间

npm install --save-dev rollup-plugin-visualizer

import { visualizer } from "rollup-plugin-visualizer";
import { defineConfig, type PluginOption } from 'vite'
export default defineConfig({
  plugins: [visualizer({
    emitFile: false,
    open: true,
    filename: 'test.html' //分析图生成的文件名
  }) as PluginOption],
})

除此之外,目前配置中,使用项目被打出的包是一个整体,为了更方便观察,我们对静态资源分类打包:

build: {
        rollupOptions: {
            // 静态资源分类打包
            output: {
                // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
                globals: {},
                chunkFileNames: 'static/js/[name]-[hash].js',
                entryFileNames: 'static/js/[name]-[hash].js',
                assetFileNames: 'static/[ext]/[name]-[hash].[ext]',
                manualChunks: (id): any => {
                    // 静态资源分拆打包
                    if (id.includes('node_modules')) {
                        return id.toString().split('node_modules/')[1].split('/')[0].toString()
                    }
                }
            }
        }
    }

因为之前是在pnpm workspace下进行测试的,因此还需要修改组件库的package.json文件,指明dist路径的文件输出:

"main": "./dist/cjs/main.cjs",
"module": "./dist/es/main.mjs",
"types": "./dist/test-ui.d.ts",

按需加载方式一:利用treeshaking

首先在使用项目中的main.ts里导入样式文件:

import 'test-ui/dist/es/style.css'

在组件中使用:

import { YButton } from 'test-ui'

但是发现,按需加载并没有成功,打出来的包仍然将input组件打进去了。

图片.png

后查看element-plus的导出方式以及各种排查发现,问题居然出现在导出组件的install文件:

import YButton from './index.vue'
import type { App } from 'vue'const install = function (app: App) {
  app.component(YButton.name || 'YButton', YButton)
}
YButton.install = install

这里直接在YButton上添加了一个install属性,方便按需加载的时候,也能在全局使用,但是正是因为这个操作,导致按需加载失败。

如果去掉installl属性,直接导出组件,是可以按需加载的,这里我暂时没有找到解决方法,模仿element-plus包装了几层,也是失败。这里如果有大佬了解原因的话可以指点一下。

按需加载方式二:使用exports字段明确模块导出内容

"exports": {
    ".": {
      "import": "./dist/es/main.mjs",
      "require": "./dist/cjs/main.cjs",
      "types": "./dist/main.d.ts"
    },
    "./YButton": {
      "import": "./dist/es/components/YButton/install.mjs",
      "require": "./dist/cjs/components/YButton/install.cjs",
      "types": "./dist/src/components/YButton/install.d.ts"
    },
    "./YInput": {
      "import": "./dist/es/components/YInput/install.mjs",
      "require": "./dist/cjs/components/YInput/install.cjs",
      "types": "./dist/src/components/YInput/install.d.ts"
    }
  }

这样导出的组件库的使用方法如下:

import YButton from 'test-ui/YButton'

如果需要类型声明,使用项目中的types字段可以为如下,如果仍旧报错的话,重启一下vscode。

"types": ["test-ui/dist/main.d.ts"],
// 或者
"types": ["test-ui/dist/src/components/YButton/install.d.ts"],

打包使用项目后发现可以正常按需导出,查看分析结果,Y-input并没有被打包,但是会报错Missing "./dist/es/style.css" specifier in "test-ui" package。样式文件无法找到。在package.json的exports字段中加入以下代码可以解决css文件未被找到的问题:

"./style": {
      "import": "./dist/es/style.css",
      "require": "./dist/cjs/style.css"
 },

或者在package.json中使用sideEffects字段,指明css文件不可被忽略:

"sideEffects": [
    "*.css",
    "*.scss",
    "./dist/es/**/*.css",
    "./dist/cjs/**/*.css"
  ]

自动导入

使用组件库时,可以利用unplugin-vue-components实现组件的自动导入。它的原理就是项目编译时,会扫描所有 Vue 文件及其他可识别的组件使用位置,解析每一个文件中的标签名称或组件引用,收集组件名称,例如 ,并通过组件命名解析器(Resolver)自动将组件名称转换为符合库导入的路径。在生成的导入中,插件还可以为每个组件添加 sideEffects 字段,用于指定与组件相关的样式文件或其他依赖文件。在编译过程中,插件将每个 Vue 文件中的组件使用情况替换成对应的导入语句。

例如,如果在模板中使用了 ,插件会在模板所在的模块头部插入 import { YButton } from 'test-ui',使得组件可以在模板中正常渲染。

因此,我们需要实现一个自己的resolver。在unplugin-vue-components中也有一些现成的组件库的resolver,如ant-design,element-plus等等。

import { ComponentResolver, ComponentResolveResult } from 'unplugin-vue-components'function toPascalCase(kebabCaseName: string): string {
    return kebabCaseName
        .replace(/-./g, (match) => match.charAt(1).toUpperCase()) // 将每个"-"后的字母大写
        .replace(/^./, (match) => match.toUpperCase()) // 将第一个字母大写
}
​
export default function testuiResolver(): ComponentResolver {
    return {
        type: 'component',
        resolve: (name: string): ComponentResolveResult => {
            let ComponentName = name
            if (name.startsWith('Y')) {
                ComponentName = toPascalCase(name)
                return {
                    as: ComponentName,
                    from: `test-ui/${ComponentName}`,
                    sideEffects: `test-ui/style`
                }
            }
            return null
        }
    }
}