从零搭建VUE3组件库

1,365 阅读7分钟

jsx 与 .vue 模板开发选择

jsx:适合高度复杂可定制化的组件使用 .vue: 适合简单的、一般的定制化组件使用

创建项目

mkdir vue3-ui-lib
cd vue3-ui-lib

项目结构

.
├── docs
├── package.json
├── packages
│   ├── components
│   │   └── package.json
│   ├── theme-chalk
│   │   └── package.json
│   └── utils
│       └── package.json
├── play
├── pnpm-workspace.yaml
├── README.md
├── tsconfig.json
└── typings
    └── vue-shime.d.ts

monorepo目录结构.png

PS: -w 等价于 --workspace-root

创建 package.json

pnpm init
{
  "name": "vue3-ui-lib",
  "private": true,
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "pnpm -C play dev"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

PS:这里 "dev": "pnpm -C play dev" 指定了在项目根目录下执行脚本命令可以执行 play 这个下面的脚本。

创建 .npmrc

touch .npmrc
# 使用 pnpm 安装包时提升模块的依赖到 node_modules 下
shamefully-hoist = true

安装 typescript

pnpm install typescript -D

创建 tsconfig.json

npx tsc --init
{
  "compilerOptions": {
    "target": "es2016", // 遵循 ES6 规范
    "module": "ESNext", // 打包模块类型
    "declaration": false, // 默认打包后不要声明文件
    "noImplicitAny": true, // 默认可以 any
    "removeComments": true, // 移除注释
    "moduleResolution": "node", // 安装 node 模块解析
    "esModuleInterop": true, // 支持 es、commonjs 模块
    "jsx": "preserve", // 告诉 ts 不处理 jsx 语法
    "noLib": false, // 不处理库
    "sourceMap": true,
    "lib": [
      // 编译时使用的库
      "ESNext",
      "DOM"
    ],
    "allowSyntheticDefaultImports": true, // 允许没有导出的模块中导入
    "experimentalDecorators": true, // 实验语法,支持装饰器
    "forceConsistentCasingInFileNames": true, // 强制区分大小写
    "resolveJsonModule": true, // 解析 json 模块
    "strict": true, // 严格模式
    "skipLibCheck": true // 跳过类库检测
  },
  "exclude": [
    // 不需要解析的文件
    "node_modules",
    "**/__tests__",
    "dist/**"
  ]
}

创建 pnpm-workspace.yaml

touch pnpm-workspace.yaml
packages:
  - "packages/**" # 存放所有编写的组件
  - play # 存放开发测试组件的代码
  - docs # 存放组件使用的开发文档

创建 typings/vue-shime.d.ts

mkdir typings
cd typings
touch vue-shime.d.ts
declare module "*.vue" {
  import type { DefineComponent } from "vue";
  const component: DefineComponent<{}, {}, any>;
  export default component;
}

在根项目 package.json 中添加我们的组件包依赖

# 切换到项目根目录执行如下命令,这样就可以在 package.json 查看依赖了
pnpm install @yun/components -w

PS:

  • -w 等价 --workspace-root
  • @yun/components:我们的组件中 package.json 中配置的 name 属性值名称

创建 play 的测试项目

mkdir play
cd play
pnpm init

pnpm install vue vue-tsc vite @vitejs/plugin-vue -D

play/package.json

pnpm init
{
  "name": "play",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "preview": "vite preview"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@vitejs/plugin-vue": "^3.0.1",
    "vite": "^3.0.2",
    "vue": "^3.2.37"
  }
}

play/vite.config.ts

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

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

play/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="src/main.ts" type="module"></script>
  </body>
</html>

play/src/main.ts

import { createApp } from "vue";
import App from "./App.vue";

const app = createApp(App);
app.mount("#app");

play/src/App.vue

<template>Test</template>

<script setup lang="ts"></script>

<style scoped></style>

BEM 类名规范

block:代码片段 => y-button
element:元素 => y-button__element
modifier:装饰 => y-button__element--disabled
state:状态 => is-checked

packages/utils/create.ts

/**
 * BEM 规范
 * block:代码片段 => y-button
 * element:元素 => y-button__element
 * modifier:装饰 => y-button__element--disabled
 * state:状态 => is-checked
 *
 * y-button
 * y-button__element
 * y-button__element--disabled
 * is-checked
 */

// z-button-box__element--modifier
function _bem(
  prefixName: string,
  blockSuffix: string,
  element: string,
  modifier: string
) {
  if (blockSuffix) {
    prefixName += `-${blockSuffix}`;
  }
  if (element) {
    prefixName += `__${element}`;
  }

  if (modifier) {
    prefixName += `--${modifier}`;
  }

  return prefixName;
}

function createBEM(prefixName: string) {
  const b = (blockSuffix: string = "") => _bem(prefixName, blockSuffix, "", "");
  const e = (element: string = "") =>
    element ? _bem(prefixName, "", element, "") : "";
  const m = (modifier: string = "") =>
    modifier ? _bem(prefixName, "", "", modifier) : "";

  const be = (blockSuffix: string = "", element: string = "") =>
    blockSuffix && element ? _bem(prefixName, blockSuffix, element, "") : "";

  const bm = (blockSuffix: string = "", modifier: string = "") =>
    blockSuffix && modifier ? _bem(prefixName, blockSuffix, "", modifier) : "";

  const em = (element: string = "", modifier: string = "") =>
    element && modifier ? _bem(prefixName, "", element, modifier) : "";

  const bem = (
    blockSuffix: string = "",
    element: string = "",
    modifier: string = ""
  ) =>
    element && modifier && blockSuffix
      ? _bem(prefixName, blockSuffix, element, modifier)
      : "";

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

// const bem = createNamespace("icon");
// console.log(bem.b()); // y-icon
// console.log(bem.b("box")); // y-icon-box
// console.log(bem.e("element")); // y-icon__element
// console.log(bem.e()); // 空
// console.log(bem.m("modifier")); // y-icon--modifier
// console.log(bem.bem("box", "element", "modifier")); // y-icon-box__element--modifier
// console.log(bem.is("checked", true)); // is-checked
// console.log(bem.is("checked", false)); // 空
// console.log(bem.be("box", "element")); // y-icon-box__element
// console.log(bem.bm("box", "modifier")); // y-icon-box--modifier
// console.log(bem.em("element", "modifier")); // y-icon__element--modifier

第一个组件:Icon

packages/components/icon/src/icon.vue

<template>
  <i :class="bem.b()" :style="style">
    <slot />
  </i>
</template>

<script setup lang="ts">
import { computed } from "vue";
import { createNamespace } from "@yun/utils/create";
import { iconProps } from "./icon";

const bem = createNamespace("icon");
const props = defineProps(iconProps);

const style = computed(() => {
  if (!props.size && !props.color) return {};

  return {
    ...(props.size ? { fontSize: props.size + "px" } : {}),
    ...(props.color ? { color: props.color } : {}),
  };
});
</script>

**注意:**这里使用了 script 的 setup 写法,不能自定义组件名,那如何解决呢?

  • 方案一:不推荐

    在对应的组件中添加脚本

    +
    <script lang="ts">
    +import { defineComponent } from "vue";
    
    +export default defineComponent({
    +  name: "YIcon", // 指定组件名
    +});
    +
    </script>
    
    <script setup lang="ts">
    // TODO
    </script>
    
  • 方案二:推荐

    1. 配置插件:unplugin-vue-define-options

      pnpm install unplugin-vue-define-options -D -w
      
    2. 配置 vite.config.ts

      import { defineConfig } from "vite";
      import vue from "@vitejs/plugin-vue";
      
      +// 解决setup编写组件时, 自定义组件名
      +import defineOptions from "unplugin-vue-define-options/vite";
      
      export default defineConfig({
        plugins: [
            vue(),
      +     defineOptions()
        ],
      });
      
    3. 在 .vue 的文件组件中调用 defineOptions 来指定组件名

      <script setup lang="ts">
      // other code ...
      
      +// 该函数调用需要在 vite.config.ts  配置插件 unplugin-vue-define-options/vite
      +defineOptions({
      +  name: "YIcon", // 指定组件名
      +});
      
      // other code ...
      </script>
      

packages/components/icon/src/icon.ts

import { ExtractPropTypes, PropType } from "vue";

export const iconProps = {
  color: String,
  size: [Number, String] as PropType<number | string>,
} as const;

export type IconProps = ExtractPropTypes<typeof iconProps>;

packages/components/icon/index.ts

import YIcon from "./src/icon.vue";

export default YIcon;

export * from "./src/icon";

使用 Icon 组件

在 play 测试项目中,使用 Icon 组件

play/src/App.vue

<template>+ <Icon>Test</Icon></template>

<script setup lang="ts">
+// import Icon from "@yun/components/icon/src/icon.vue";
+import Icon from "@yun/components/icon";
+console.log(Icon);
</script>

<style scoped></style>

通过 vue.use() 注册我们的组件

packages/components/icon/index.ts

import { Plugin } from "vue";
import _Icon from "./src/icon.vue";

//#region
export type SFCWithInatall<T> = T & Plugin;
/**
 * 为组件添加 install 方法,便于 vue.use 使用该组件
 * @param comp 组件对象
 * @returns 组件
 */
export function withInstall<T>(comp: T) {
  (comp as SFCWithInatall<T>).install = function (app) {
    // 全局注册组件
    app.component((comp as unknown as { name: string }).name, comp);
  };

  return comp as SFCWithInatall<T>;
}
//#endregion

export const YIcon = withInstall(_Icon);

export default YIcon;
export * from "./src/icon";

// 为了在template模板中使用组件时,有对应的 ts 提示功能
declare module "vue" {
  export interface GlobalComponents {
    YIcon: typeof YIcon;
  }
}

volar 配合在 template 模板中使用组件有代码提示

// 为了在模板中使用组件时,有对应的 ts 提示功能
declare module "vue" {
  export interface GlobalComponents {
    YIcon: typeof YIcon;
  }
}

play/src/main.ts

import { createApp } from "vue";
import App from "./App.vue";

+import YIcon from "@yun/components/icon";

const app = createApp(App);

+const plugins = [YIcon];
+plugins.forEach((plugin) => app.use(plugin));

app.mount("#app");

play/src/App.vue

<template>
  <YIcon :color="'red'" :size="18">已经在main.ts注册了该组件,直接使用</YIcon>
  <y-icon color="skyblue" :size="14"
    >已经在main.ts注册了该组件,直接使用</y-icon
  >
</template>

VitePress 编写文档

安装 vitepress

mkdir docs
cd docs
pnpm init
pnpm install vitepress -D

docs/package.json

{
  "name": "docs",
  "scripts": {
    "dev": "vitepress dev ."
  },
  "devDependencies": {
    "vitepress": "1.0.0-alpha.4"
  }
}

项目根目录 package.json 配置执行脚本

"scripts": {
+    "docs:dev": "pnpm -C docs dev"
},

首页配置

docs/index.md

---
layout: home

hero:
  name: yun-ui 组件库
  text: 基于 Vue 3 的组件库.
  tagline: 掌握 Vue3 组件编写
  actions:
    - theme: brand
      text: 快速开始
      link: /guide/quickStart

features:
  - icon: 🛠️
    title: 组件库构建流程
    details: Vue3 组件库构建 ...
  - icon: ⚙️
    title: 组件库单元测试
    details: Vue3 组件库测试 ...
---

vite.config.ts

docs/vite.config.ts

import { defineConfig } from "vite";

// 解决在 script 的 setup 中定义组件名: defineOptions({name: "YIcon"})
import DefineOptions from "unplugin-vue-define-options/vite";

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

文档配置

docs/ .vitepress/config.js

module.exports = {
  title: "YUN-UI",
  description: "Vue3 YUN UI",
  themeConfig: {
    lastUpdated: "最后更新时间",
    docsDir: "docs",
    editLinks: true,
    editLinkText: "编辑此网站",
    repo: "https://gitee.com/login",
    footer: {
      message: "Released under the MIT License.",
      copyright: "Copyright © 2022-present Timly",
    },
    nav: [
      { text: "指南", link: "/guide/installation", activeMatch: "/guide/" },
      { text: "组件", link: "/component/icon", activeMatch: "/component/" },
    ],
    sidebar: {
      "/guide/": [
        {
          text: "指南",
          items: [
            { text: "安装", link: "/guide/installation" },
            { text: "快速开始", link: "/guide/quickStart" },
          ],
        },
      ],
      "/component/": [
        {
          text: "基础组件",
          items: [{ text: "Icon", link: "/component/icon" }],
        },
      ],
    },
  },
};

注册组件

在 vitepress 中注册我们的组件

docs/ .vitepress/theme/index.js

import DefaultTheme from "vitepress/theme";

import YIcon from "@yun/components/icon";
import "@yun/theme-chalk/src/index.scss";

console.log(YIcon);
export default {
  ...DefaultTheme,
  enhanceApp({ app }) {
    app.use(YIcon); // 在 vitepress 中,注册全局组件
  },
};

guide

docs/guide/installation.md

# 安装

docs/guide/quickStart.md

# 快速开始

组件文档

编写我们的组件文档

docs/component/icon.md

# Icon 图标

yun-ui 推荐使用 [xicons](https://www.xicons.org/#/) 作为图标库。

```sh
pnpm install @vicons/material
```

## 使用图标

- 如果你想像用例一样直接使用,你需要全局注册组件,才能够直接在项目里使用。

<script setup lang="ts">
import {AddBoxOutlined} from "@vicons/material";
</script>

<y-icon :size="18" color="red">
  <AddBoxOutlined />
</y-icon>

<y-icon :size="34" color="skyblue">
  <AddBoxOutlined />
</y-icon>

```vue
<script setup lang="ts">
import { AddBoxOutlined } from "@vicons/material";
</script>

<template>
  <y-icon :size="18" color="red">
    <AddBoxOutlined />
  </y-icon>

  <y-icon :size="34" color="skyblue">
    <AddBoxOutlined />
  </y-icon>
</template>
```

## API

### Icon Props

| 名称  | 类型             | 默认值    | 说明     |
| ----- | ---------------- | --------- | -------- |
| color | string           | undefined | 图标颜色 |
| size  | number \| string | undefined | 图标大小 |

Eslint

npx eslint --init

安装依赖

pnpm install eslint-plugin-vue@latest @typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest eslint@latest -D -w
# 支持 vue 的 typescript 的 eslint 配置
pnpm install @vue/eslint-config-typescript -D -w

.eslintrc.js

module.exports = {
  env: {
    browser: true,
    es2021: true,
    node: true,
  },
  extends: [
    "eslint:recommended",
    "plugin:vue/vue3-recommended", // https://eslint.vuejs.org/
    "plugin:@typescript-eslint/recommended",
    "@vue/typescript/recommended",
  ],
  parserOptions: {
    ecmaVersion: "latest",
    parser: "@typescript-eslint/parser",
    sourceType: "module",
  },
  plugins: ["vue", "@typescript-eslint"],
  rules: {
    "vue/html-self-closing": "off",
    "vue/singleline-html-element-content-newline": "off",
    "vue/multi-word-component-names": "off",
    "vue/prefer-import-from-vue": "off",
  },
  globals: {
    defineOptions: "readonly",
  },
};

.eslintignore

node_modules
dist
*.css
*.jpg
*.jpeg
*.png
*.gif
*.d.ts

prettier

.prettierrc.js

module.exports = {
  singleQuote: false, // 使用双引号
  semi: true, // 使用分号
  trailingComma: 'none', // 末尾逗号
  arrowParens: 'avoid', //  箭头函数括号
  endOfLine: 'auto' // 结尾换行自动
}

.prettierignore

node_modules
dist

编辑器

.editorconfig

root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf

打包 packages/theme-chalk 样式

安装

pnpm install gulp @types/gulp sucrase -D -w

pnpm install gulp-sass gulp-autoprefixer gulp-clean-css sass @types/gulp-sass @types/sass @types/gulp-autoprefixer @types/gulp-clean-css -D -w

packages.json

  • 配置项目根目录下 package.json 的执行脚本
"scripts": {
    "dev": "pnpm -C play dev",
    "docs:dev": "pnpm -C docs dev",
+   "build": "gulp -f build/gulpfile.ts"
},

build/gulpfile.ts

import { series } from "gulp";
import { run, withTaskName } from "./tools";

// 清除根目录下已经打包的 dist
const cleanDist = withTaskName("clean", async () => run("rm -rvf ./dist"));

// 执行 packages 目录中 package.json 配置有 build 的对应命令脚本
const buildPackages = withTaskName("buildPackages", async () =>
  // 注意这里 --filter ./packages,在 pnpm7.6.0 中是找不到 packages 下的项目的,有 bug
  run("pnpm run --filter ./packages --parallel build")
);

export default series(cleanDist, buildPackages);

build/tools/index.ts

import { spawn } from "child_process";
import { projectRoot } from "./paths";

export const withTaskName = <T>(name: string, fn: T) =>
  Object.assign(fn, { displayName: name });

export const run = (command: string) => {
  return new Promise((resolve, rejected) => {
    const [cmd, ...args] = command.split(" ");
    // 执行命令
    const app = spawn(cmd, args, {
      cwd: projectRoot,
      stdio: "inherit", // 将子进程输出共享给父进程
      shell: true
    });
    app.on("close", resolve);
  });
};

build/tools/paths.ts

import path from "path";

export const projectRoot = path.resolve(__dirname, "../../");

packages/theme-chalk/package.json

"scripts": {
+   "build": "gulp"
},

packages/theme-chalk/gulpfile.ts

import path from "path";
import { series, src, dest } from "gulp";
import gulpSass from "gulp-sass";
import sass from "sass";
import gulpCleanCss from "gulp-clean-css";
import autoPrefixer from "gulp-autoprefixer";

function r(p: string) {
  return path.resolve(__dirname, p);
}

// 编译 sass 到 css
const complieSassToCss = () => {
  return src(r("./src/*.scss"))
    .pipe(gulpSass(sass).sync())
    .pipe(autoPrefixer())
    .pipe(gulpCleanCss())
    .pipe(dest("./dist/css"));
};

// 拷贝字体图标
const copyFont = () => {
  return src(r("./src/font/**")).pipe(dest("./dist/fonts"));
};

// 把编译好的 css 拷贝到根目录下 dist 中
const copyFullStyle = () => {
  return src(r("./dist/**")).pipe(dest(r("../../dist/theme-chalk")));
};

// 注意执行顺序
export default series(complieSassToCss, copyFont, copyFullStyle);

打包 packages/utils 工具

安装

pnpm install gulp-typescript -D -w

build/tools/config.ts

import { resolve } from "path";
import { outDir } from "./paths";

export const buildConfig = {
  esm: {
    module: "ESNext", // tsconfig 输出的结果 ES6 模块
    format: "esm", // 需要配置格式化化后的模块规范
    output: {
      name: "es", // 打包到 dist 目录下的那个目录
      path: resolve(outDir, "es")
    },
    bundle: {
      path: "yun-ui/es"
    }
  },
  cjs: {
    module: "CommonJS",
    format: "cjs",
    output: {
      name: "lib",
      path: resolve(outDir, "lib")
    },
    bundle: {
      path: "yun-ui/lib"
    }
  }
};

export type BuildConfig = typeof buildConfig;

build/tools/path.ts

import path from "path";

export const projectRoot = path.resolve(__dirname, "../../");
+ export const outDir = path.resolve(__dirname, "../../dist");

build/packages.ts

import path from "path";
import { src, series, dest, parallel } from "gulp";
import ts from "gulp-typescript";

import { withTaskName } from "./tools";
import { buildConfig } from "./tools/config";
import { outDir, projectRoot } from "./tools/paths";

export function buildPackages(dirname: string, name: string) {
  const tasks = Object.entries(buildConfig).map(([module, config]) => {
    const output = path.resolve(dirname, config.output.name);

    const build = withTaskName(`build:${name || dirname}`, () => {
      const tsConfig = path.resolve(projectRoot, "tsconfig.json"); // ts的配置文件的路径
      const inputs = ["**/*.ts", "!gulpfile.ts", "!node_modules"];
      return src(inputs)
        .pipe(
          ts.createProject(tsConfig, {
            declaration: true, // 需要生成声明文件
            strict: false,
            module: config.module
          })()
        )
        .pipe(dest(output));
    });

    const copy = withTaskName(`copy:${name || dirname}`, () => {
      // 将 utils 模块编译 ts 后的 js 拷贝到 dist 目录下的 es 目录和 lib 目录
      return src(`${output}/**`).pipe(
        dest(path.resolve(outDir, config.output.name, name))
      );
    });
    return series(build, copy);
  });
  // 将 ts -> js, 也可以使用 rullup 处理
  return parallel(...tasks);
}

packages/utils/packages.json

"scripts": {
+    "build": "gulp"
},

packages/utils/gulpfile.ts

import { buildPackages } from "../../build/packages";

export default buildPackages(__dirname, "utils");

打包 packages/components 组件

安装

pnpm install rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs rollup-plugin-vue rollup-plugin-typescript2 -D -w

打包为整个组件文件

build/gulpfile.ts

import { series } from "gulp";
import { run, withTaskName } from "./tools";

// 清除根目录下已经打包的 dist
const cleanDist = withTaskName("clean", async () => run("rm -rf ./dist"));

// 执行 packages 目录中 package.json 配置有 build 的对应命令脚本
const buildPackages = withTaskName("buildPackages", async () =>
  // 注意这里 --filter ./packages,在 pnpm7.6.0 中是找不到 packages 下的项目的,有 bug
  run("pnpm run --filter ./packages --parallel build")
);

+// 处理 packages/components 的所有组件成一个单文件
+const buildAllComponent = withTaskName("buildFullComponent", async () =>
+  // 会去找 full-component.ts 中 buildFullComponent
+  run("pnpm run build buildFullComponent")
+);

// buildFullComponent 函数在 full-component.ts 中
+export * from "./full-component";

export default series(
  cleanDist,
  buildPackages,
+  buildAllComponent
);

build/tools/paths.ts

import path from "path";

export const projectRoot = path.resolve(__dirname, "../../");
export const outDir = path.resolve(__dirname, "../../dist");
+export const yunRoot = path.resolve(__dirname, "../../packages/yun");

build/full-component.ts

import path from "path";
import fs from "fs/promises";
import vue from "rollup-plugin-vue";
import typescript from "rollup-plugin-typescript2";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import { parallel } from "gulp";
import { OutputOptions, rollup } from "rollup";

import { outDir, yunRoot } from "./tools/paths";

const buildFull = async () => {
  console.log("-----------------buildFull------------------------");
  // rollup打包的配置信息
  const config = {
    input: path.resolve(yunRoot, "index.ts"), // 打包的入口
    plugins: [nodeResolve(), typescript(), vue(), commonjs()],
    external: (id: string) => /^vue/.test(id) // 排除打包的时候不需要打包 vue 代码
  };

  const buildConfig = [
    {
      format: "umd",
      file: path.resolve(outDir, "index.js"),
      name: "yun", // 全局的名字
      exports: "named", // 导出的名字用命名的方式导出 liraryTarget:"var"  var name = "xxx"
      globals: {
        // 表示使用的 vue 是全局的
        vue: "Vue"
      }
    },
    {
      format: "esm",
      file: path.resolve(outDir, "index.esm.js")
    }
  ];

  const bundle = await rollup(config);

  return Promise.all(
    buildConfig.map(config => bundle.write(config as OutputOptions))
  );
};

export const buildFullComponent = parallel(buildFull);

打包成多个组件文件(支持引入组件库时可以按需加载)

安装

pnpm install fast-glob -D -w

build/gulpfile.ts

import { series } from "gulp";
import { run, withTaskName } from "./tools";

// 清除根目录下已经打包的 dist
const cleanDist = withTaskName("clean", async () => run("rm -rf ./dist"));

// 执行 packages 目录中 package.json 配置有 build 的对应命令脚本
const buildPackages = withTaskName("buildPackages", async () =>
  // 注意这里 --filter ./packages,在 pnpm7.6.0 中是找不到 packages 下的项目的,有 bug
  run("pnpm run --filter ./packages --parallel build")
);

// 处理 packages/components 的所有组件成一个文件
const buildAllComponent = withTaskName("buildFullComponent", async () =>
  // 会去找 full-component.ts 中 buildFullComponent
  run("pnpm run build buildFullComponent")
);

+// 引用我们组件时,打包按需加载的多个文件组件
+const buildSingleComponent = withTaskName("buildComponent", () =>
+  // 会去找 component.ts 中 buildComponent
+  run("pnpm run build buildComponent")
+);

// buildFullComponent 函数在 full-component.ts 中
export * from "./full-component";

+// buildComponent 函数在 component.ts 中
+export * from "./component";

export default series(
  cleanDist,
  buildPackages,
  buildAllComponent,
+  buildSingleComponent
);

build/tools/paths.ts

import path from "path";

export const projectRoot = path.resolve(__dirname, "../../");
export const outDir = path.resolve(__dirname, "../../dist");
export const yunRoot = path.resolve(__dirname, "../../packages/yun");
+export const componentRoot = path.resolve(projectRoot, "packages/components");

build/tools/index.ts

import { spawn } from "child_process";
import { projectRoot } from "./paths";

export const withTaskName = <T>(name: string, fn: T) =>
  Object.assign(fn, { displayName: name });

export const run = (command: string) => {
  return new Promise((resolve, rejected) => {
    const [cmd, ...args] = command.split(" ");
    // 执行命令
    const app = spawn(cmd, args, {
      cwd: projectRoot,
      stdio: "inherit", // 将子进程输出共享给父进程
      shell: true
    });
    app.on("close", resolve);
  });
};

+export const pathRewriter = (format: string) => {
+  return (id: string) => {
+    id = id.replaceAll("@yun", `yun/${format}`);
+    return id;
+  };
+};

build/component.ts

import path from "path";
import { series, parallel } from "gulp";
import { sync } from "fast-glob";
import vue from "rollup-plugin-vue";
import typescript from "rollup-plugin-typescript2";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import { OutputOptions, rollup } from "rollup";

import { componentRoot } from "./tools/paths";
import { buildConfig } from "./tools/config";
import { pathRewriter } from "./tools";

const buildEachComponent = async () => {
  console.log("------------------buildEachComponent-------------------");
  // 打包每个组件
  const files = sync("*", {
    cwd: componentRoot,
    onlyDirectories: true
  });

  // 分别把 components 文件夹下的组件,放到 dist/es/components 下 和 dist/lib/compmonents
  const builds = files.map(async (file: string) => {
    // 每个组件的入口
    const input = path.resolve(componentRoot, file, "index.ts");

    const config = {
      input,
      plugins: [nodeResolve(), vue(), typescript(), commonjs()],
      external: (id: string) => /^vue/.test(id) || /^@yun/.test(id) // 排除引入包那些不需要打包
    };

    const bundle = await rollup(config);

    const options = Object.values(buildConfig).map(config => ({
      format: config.format,
      file: path.resolve(config.output.path, `components/${file}/index.js`),
      paths: pathRewriter(config.output.name) // @yun => yun/es  yun/lib

      // 视情况,是否配置,否在会包报警告 Use `output.exports: "named"` to disable this warning
      // name: "yun", // 全局的名字
      // exports: "named" // 导出的名字用命名的方式导出 liraryTarget:"var"  var name = "xxx"
    }));

    await Promise.all(
      options.map(option => bundle.write(option as OutputOptions))
    );
  });

  return Promise.all(builds);
};

export const buildComponent = series(buildEachComponent);

解析 .vue 文件中 指定 lang="ts" 的代码

安装

pnpm install ts-morph -D -w

生成 types 类型文件 .d.ts

build/component.ts
import path from "path";
import fs from "fs/promises";
import { series, parallel } from "gulp";
import glob, { sync } from "fast-glob";
import vue from "rollup-plugin-vue";
import typescript from "rollup-plugin-typescript2";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import { OutputOptions, rollup } from "rollup";

import { Project, SourceFile } from "ts-morph";
import * as VueCompiler from "@vue/compiler-sfc";

import { componentRoot, outDir, projectRoot } from "./tools/paths";
import { buildConfig } from "./tools/config";
import { pathRewriter, run } from "./tools";

const buildEachComponent = async () => {
  console.log("------------------buildEachComponent---------------------");
  // 打包每个组件
  const files = sync("*", {
    cwd: componentRoot,
    onlyDirectories: true
  });

  // 分别把 components 文件夹下的组件,放到 dist/es/components 下 和 dist/lib/compmonents
  const builds = files.map(async (file: string) => {
    // 每个组件的入口
    const input = path.resolve(componentRoot, file, "index.ts");

    const config = {
      input,
      plugins: [nodeResolve(), vue(), typescript(), commonjs()],
      external: (id: string) => /^vue/.test(id) || /^@yun/.test(id) // 排除引入包那些不需要打包
    };

    const bundle = await rollup(config);

    const options = Object.values(buildConfig).map(config => ({
      format: config.format,
      file: path.resolve(config.output.path, `components/${file}/index.js`),
      paths: pathRewriter(config.output.name), // @yun => yun/es  yun/lib

      // 视情况,是否配置,否在会包报警告 Use `output.exports: "named"` to disable this warning
      name: "yun", // 全局的名字
      exports: "named" // 导出的名字用命名的方式导出 liraryTarget:"var"  var name = "xxx"
    }));

    await Promise.all(
      options.map(option => bundle.write(option as OutputOptions))
    );
  });

  return Promise.all(builds);
};

// 生成 ts 的 type 文件
+const genTypes = async () => {
+  console.log("----------------genTypes--------------------");
+
+  const project = new Project({
+    // 生成 .d.ts,需要有一个 tsconfig.json
+    compilerOptions: {
+      allowJs: true,
+      declaration: true,
+      emitDeclarationOnly: true,
+      noEmitOnError: true,
+      outDir: path.resolve(outDir, "types"),
+      baseUrl: projectRoot,
+      paths: {
+        "@yun/*": ["packages/*"]
+      },
+      skipLibCheck: true,
+      strict: false
+    },
+    tsConfigFilePath: path.resolve(projectRoot, "tsconfig.json"),
+    skipAddingFilesFromTsConfig: true
+  });
+
+  const filePaths = await glob("**/*", {
+    // ** 任意目录  * 任意文件
+    cwd: componentRoot,
+    onlyFiles: true,
+    absolute: true
+  });
+
+  const sourceFiles: SourceFile[] = [];
+
+  await Promise.all(
+    filePaths.map(async function (file) {
+      if (file.endsWith(".vue")) {
+        const content = await fs.readFile(file, "utf8");
+        const sfc = VueCompiler.parse(content);
+        const { script } = sfc.descriptor;
+
+        if (script) {
+          const content = script.content; // 拿到脚本  icon.vue.ts  => icon.vue.d.ts
+          const sourceFile = project.createSourceFile(file + ".ts", content);
+          sourceFiles.push(sourceFile);
+        }
+      } else {
+        // 把所有的 ts 文件都放在一起发射成 .d.ts 文件
+        const sourceFile = project.addSourceFileAtPath(file);
+        sourceFiles.push(sourceFile);
+      }
+    })
+  );
+
+  await project.emit({
+    emitOnlyDtsFiles: true // 默认是放到内存中的
+  });
+
+  const tasks = sourceFiles.map(async (sourceFile: any) => {
+    const emitOutput = sourceFile.getEmitOutput();
+    const tasks = emitOutput.getOutputFiles().map(async (outputFile: any) => {
+      const filepath = outputFile.getFilePath();
+      await fs.mkdir(path.dirname(filepath), {
+        recursive: true
+      });
+      // @yun -> yun/es -> .d.ts 不用去 lib 下查找
+      await fs.writeFile(filepath, pathRewriter("es")(outputFile.getText()));
+    });
+    await Promise.all(tasks);
+  });
+
+  await Promise.all(tasks);
+};

export const buildComponent = series(
  buildEachComponent,
+  genTypes
);

对应的组件生成对应的类型文件 .d.ts

build/component.ts
const copyTypes = () => {
  console.log("----------------copyTypes--------------------");

  const src = path.resolve(outDir, "types/components/");

  const copy = (module: string) => {
    const output = path.resolve(outDir, module, "components");
    return () => run(`cp -r ${src}/* ${output}`);
  };

  return parallel(copy("es"), copy("lib"));
};

注意:还要保证每个组件的 index.js 的入口存在

// build/component.ts
const buildComponentEntry = async () => {
  console.log("----------------buildComponentEntry--------------------");
  const config = {
    input: path.resolve(componentRoot, "index.ts"),
    plugins: [typescript()],
    external: () => true
  };

  const bundle = await rollup(config);

  return Promise.all(
    Object.values(buildConfig)
      .map(config => ({
        format: config.format,
        file: path.resolve(config.output.path, "components/index.js")
      }))
      .map(config => bundle.write(config as OutputOptions))
  );
};

打包组件库入口 yun 生成入口文件 index.js 和类型文件 .d.ts

build/full-component.ts 生成入口文件 index.js

const buildEntry = async () => {
  console.log("-----------------buildEntry------------------------");

  const entryFiles = await fs.readdir(yunRoot, { withFileTypes: true });

  const entryPoints = entryFiles
    .filter(f => f.isFile())
    .filter(f => !["package.json"].includes(f.name))
    .map(f => path.resolve(yunRoot, f.name));

  const config = {
    input: entryPoints,
    plugins: [nodeResolve(), vue(), typescript()],
    external: (id: string) => /^vue/.test(id) || /^@yun/.test(id)
  };

  const bundle = await rollup(config);

  return Promise.all(
    Object.values(buildConfig)
      .map(config => ({
        format: config.format,
        dir: config.output.path,
        paths: pathRewriter(config.output.name),

        // 视情况,是否配置,否在会包报警告 Use `output.exports: "named"` to disable this warning
        name: "yun", // 全局的名字
        exports: "named" // 导出的名字用命名的方式导出 liraryTarget:"var"  var name = "xxx"
      }))
      .map(option => bundle.write(option as OutputOptions))
  );
};

build/entry-types.ts 生成 yun 入口文件的类型文件 .d.ts

import path from "path";
import fs from "fs/promises";
import glob from "fast-glob";
import { ModuleKind, Project, ScriptTarget, SourceFile } from "ts-morph";

import { outDir, projectRoot, yunRoot } from "./tools/paths";
import { parallel, series } from "gulp";
import { run, withTaskName } from "./tools";
import { buildConfig } from "./tools/config";

const genEntryTypes = async () => {
  console.log("---------------genEntryTypes------------------------");

  const files = await glob("*.ts", {
    cwd: yunRoot,
    absolute: true,
    onlyFiles: true
  });

  const project = new Project({
    compilerOptions: {
      declaration: true,
      module: ModuleKind.ESNext,
      allowJs: true,
      emitDeclarationOnly: true,
      noEmitOnError: false,
      outDir: path.resolve(outDir, "entry/types"),
      target: ScriptTarget.ESNext,
      rootDir: yunRoot,
      strict: false
    },
    skipFileDependencyResolution: true,
    tsConfigFilePath: path.resolve(projectRoot, "tsconfig.json"),
    skipAddingFilesFromTsConfig: true
  });

  const sourceFiles: SourceFile[] = [];

  files.map(f => {
    const sourceFile = project.addSourceFileAtPath(f);
    sourceFiles.push(sourceFile);
  });

  await project.emit({
    emitOnlyDtsFiles: true
  });

  const tasks = sourceFiles.map(async sourceFile => {
    const emitOutput = sourceFile.getEmitOutput();
    for (const outputFile of emitOutput.getOutputFiles()) {
      const filepath = outputFile.getFilePath();
      await fs.mkdir(path.dirname(filepath), { recursive: true });
      await fs.writeFile(
        filepath,
        outputFile.getText().replaceAll("@yun", "."),
        "utf8"
      );
    }
  });
  await Promise.all(tasks);
};

const copyEntryTypes = () => {
  const src = path.resolve(outDir, "entry/types");
  const copy = (module: any) =>
    parallel(
      withTaskName(`copyEntryTypes:${module}`, async () => {
        const p = path.resolve(
          outDir,
          (buildConfig as any)[module].output.path
        );
        return run(`cp -r ${src}/* ${p}/`);
      })
    );
  return parallel(copy("esm"), copy("cjs"));
};

export const genTypes = series(genEntryTypes, copyEntryTypes());

build/gulpfile.ts

import { series, parallel } from "gulp";
+import { genEntryTypes } from "./entry-types";
import { run, withTaskName } from "./tools";

// 清除根目录下已经打包的 dist
const cleanDist = withTaskName("clean", async () => run("rm -rf ./dist"));

// 执行 packages 目录中 package.json 配置有 build 的对应命令脚本
const buildPackages = withTaskName("buildPackages", async () =>
  // 注意这里 --filter ./packages,在 pnpm7.6.0 中是找不到 packages 下的项目的,有 bug
  run("pnpm run --filter ./packages --parallel build")
);

// 处理 packages/components 的所有组件成一个文件
const buildAllComponent = withTaskName("buildFullComponent", async () =>
  // 会去找 full-component.ts 中 buildFullComponent
  run("pnpm run build buildFullComponent")
);

// 引用我们组件时,打包按需加载的多个文件组件
const buildSingleComponent = withTaskName("buildComponent", () =>
  // 会去找 component.ts 中 buildComponent
  run("pnpm run build buildComponent")
);
// buildFullComponent 函数在 full-component.ts 中
export * from "./full-component";
// buildComponent 函数在 component.ts 中
export * from "./component";

export default series(
  cleanDist,
  buildPackages,
  buildAllComponent,
  buildSingleComponent,
+  parallel(genEntryTypes)
)

拷贝 package.json

build/gulpfile.ts

import { series, parallel } from "gulp";
import { genTypes } from "./entry-types";
import { run, withTaskName } from "./tools";
+import { outDir, yunRoot } from "./tools/paths";

// 清除根目录下已经打包的 dist
const cleanDist = withTaskName("clean", async () => run("rm -rf ./dist"));

// 执行 packages 目录中 package.json 配置有 build 的对应命令脚本
const buildPackages = withTaskName("buildPackages", async () =>
  // 注意这里 --filter ./packages,在 pnpm7.6.0 中是找不到 packages 下的项目的,有 bug
  run("pnpm run --filter ./packages --parallel build")
);

// 处理 packages/components 的所有组件成一个文件
const buildAllComponent = withTaskName("buildFullComponent", async () =>
  // 会去找 full-component.ts 中 buildFullComponent
  run("pnpm run build buildFullComponent")
);

// 引用我们组件时,打包按需加载的多个文件组件
const buildSingleComponent = withTaskName("buildComponent", () =>
  // 会去找 component.ts 中 buildComponent
  run("pnpm run build buildComponent")
);
// buildFullComponent 函数在 full-component.ts 中
export * from "./full-component";
// buildComponent 函数在 component.ts 中
export * from "./component";

+// 拷贝 package.json
+const copySourceCode = () => async () => {
+  await run(`cp ${yunRoot}/package.json ${outDir}/package.json`);
+};

export default series(
  cleanDist,
  buildPackages,
  buildAllComponent,
  buildSingleComponent,
  parallel(genTypes,
+   copySourceCode())
);