手把手实现一个组件库搭建&打包构建

435 阅读3分钟

element-plus组件库搭建

说到组件库,大家很熟悉的element-ui,和AndDesign, 我们参考一下element-plus,从0到1实现一个组件库;

先来看看element-plus的目录结构,可以发现:

  1. 采用monorepo + pnpm来搭建的组件库

为什么选择pnpm来作为包管理器搭建组件库,可以看上一篇文章pnpm入门, 目前pnpm内置了对monorepo的workspace的天然支持

用monorepo + pnpm搭建组件库

想实现的效果是实现一两个组件,并完善内部的结构,构建打包等流程;

  • components组件库
    • button组件
  • theme主题

用pnpm初始化一个全新的仓库

初始化目录结构

mkdir component-demo
// 初始化package.json
pnpm init
cd component-demo
mkdir pacakage.json
touch pnpm-workspace.yaml
  • 组装完以下目录文件
  • 在实现完components-button/ components-input基础功能之后,开始继续打包构建

element-plus组件构建流程分析

package.json script中表述了构建脚本,利用pnpm执行internal/build目录下的 start脚本;

    "build": "pnpm run -C internal/build start",

在start脚本中,采用gulp,可以看上一篇文章gulp入门, 执行gulpfile.ts来进行任务的构建:

    "start": "gulp --require @esbuild-kit/cjs-loader -f gulpfile.ts",

series是gulp中同步执行多个任务,主要是先clean,然后再执行构建,先将上一次打包构建的结果清空后,开始并行执行这次的打包构建;

export default series(
  withTaskName('clean', () => run('pnpm run clean')),
  withTaskName('createOutput', () => mkdir(epOutput, { recursive: true })),

  parallel(
    runTask('buildModules'),
    runTask('buildFullBundle'),
    runTask('generateTypesDefinitions'),
    runTask('buildHelper'),
    series(
      withTaskName('buildThemeChalk', () =>
        run('pnpm run -C packages/theme-chalk build')
      ),
      copyFullStyle
    )
  ),
  parallel(copyTypesDefinitions, copyFiles)
)

在buildModules中,主要是用rollup打包element-plus组件的相关代码,然后输出commonJS, ES Module的包,现在来分析buildModules任务中主要做了什么,是怎么实现的

组件库从0到1构建

之前我们初始化的组件库

组件打包输出CommonJS&ES-module

为什么组件打包构建要输出两种模块类型,ES Module是浏览器新提出的模块规范,在Node.js 13之前不支持ES Module,只支持CommonJS,所以为了向下兼容,一般的npm都提供两种模块的输出, 以下是我们基于rollup来构建的产物

  • 所谓打包,就是将代码进行一定的转换,然后从入口文件开始,分析其依赖关系,转成一定关系的js文件;

基于rollup来进行,我们执行指定input和external,内部依赖关系,都由rollup来进行处理了。

  • 对于特殊文件,比如vue,采用插件@vitejs/plugin-vue来进行解析;
  • 对于TS文件,采用rollup-plugin-esbuild插件来进行解析;
import fs from 'node:fs';
import { rollup } from 'rollup';
import vue from '@vitejs/plugin-vue';
import VueMacros from 'unplugin-vue-macros/rollup';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import esbuild from 'rollup-plugin-esbuild';
import glob from 'fast-glob';
import type { OutputOptions } from 'rollup';
import { resolvePackagePath } from './util';

// 将第三方包给剔除在外;
const getExternal = async (pkgDirName: string) => {
  const pkgPath = resolvePackagePath(pkgDirName, 'package.json');
  const manifest = require(pkgPath) as any;
  const {
    dependencies = {},
    peerDependencies = {},
    devDependencies = {}
  } = manifest;
  const deps: string[] = [
    ...new Set([
      ...Object.keys(dependencies),
      ...Object.keys(peerDependencies),
      ...Object.keys(devDependencies)
    ])
  ];
  return (id: string) => {
    if (id.endsWith('.less')) {
      return true;
    }
    return deps.some((pkg) => id === pkg || id.startsWith(`${pkg}/`));
  };
};

const build = async (pkgDirName: string) => {
  const pkgDistPath = resolvePackagePath(pkgDirName, 'dist');
  // 判断是否有dist目录,如果有就移除
  if (fs.existsSync(pkgDistPath) && fs.statSync(pkgDistPath).isDirectory()) {
    fs.rmSync(pkgDistPath, { recursive: true });
  }

  // 扫描所有的资源;
  const input = await glob(['**/*.{js,jsx,ts,tsx,vue}', '!node_modules'], {
    cwd: resolvePackagePath(pkgDirName, 'src'),
    absolute: true,
    onlyFiles: true
  });

  // 1. rollup打包,插件支持和配置;
  const bundle = await rollup({
    input,
    plugins: [
      // 插件VueMacros, 实现的是vue非官方提案和想法的库,探索并扩展其功能和语法;
      VueMacros({
        setupComponent: false,
        setupSFC: false,
        plugins: {
          vue: vue({
            isProduction: true
          }),
        }
      }),
      // 可以在Rollup打包时解析Node.js模块
      nodeResolve({
        extensions: ['.mjs', '.js', '.json', '.ts']
      }),
      // 可以将commonjs模块转换成ES模块,来进行打包;
      commonjs(),
      // 它结合了 ESBuild 和 Rollup 来转换 ESNext 和 TypeScript 代码
      esbuild({
        sourceMap: true,
        target: 'es2015',
        loaders: {
          '.vue': 'ts'
        }
      })
    ],
    // 将node_modules中模块剔除
    external: await getExternal(pkgDirName),
    treeshake: false
  });

  const options: OutputOptions[] = [
    {
      format: 'cjs',
      dir: resolvePackagePath(pkgDirName, 'dist', 'cjs'),
      exports: 'named',
      preserveModules: true,
      preserveModulesRoot: resolvePackagePath(pkgDirName, 'src'),
      sourcemap: true,
      entryFileNames: '[name].cjs'
    },
    {
      format: 'esm',
      dir: resolvePackagePath(pkgDirName, 'dist', 'esm'),
      exports: undefined,
      preserveModules: true,
      preserveModulesRoot: resolvePackagePath(pkgDirName, 'src'),
      sourcemap: true,
      entryFileNames: '[name].mjs'
    }
  ];
  // 开始打包,采用rollup打包, 打包输出common esmodule;
  return Promise.all(options.map((option) => bundle.write(option)));
};

await build('components');

Style的构建

组件中,我们将style单独维护在style中,采用css预处理器来编写,所以在对style文件进行打包构建的时候,还需要利用css预处理器的转换功能转换成css。

TypeScripe类型文件的构建

要生成TS类型文件,本质上是对TS代码进行编译,操作,然后生成单独的类型声明文件,借助ts-morph编译器来实现对TS文件类型的获取,但是对于vue文件,我们还需要借助compiler-src进行对vue文件的编译,提取出内部的script文件后借助ts-morph来进行产出类型文件

import process from 'node:process';
import path from 'node:path';
import fs from 'node:fs';
import * as vueCompiler from 'vue/compiler-sfc';
import glob from 'fast-glob';
import { Project } from 'ts-morph';
import type { CompilerOptions, SourceFile } from 'ts-morph';
import { resolveProjectPath, resolvePackagePath } from './util';

const tsWebBuildConfigPath = resolveProjectPath('tsconfig.json');

// 将*.d.ts文件复制到指定格式模块目录里
async function copyDts(pkgDirName: string) {
  const dtsPaths = await glob(['**/*.d.ts'], {
    cwd: resolveProjectPath('dist', 'types', 'packages', pkgDirName, 'src'),
    absolute: false,
    onlyFiles: true
  });

  dtsPaths.forEach((dts: string) => {
    const dtsPath = resolveProjectPath(
      'dist',
      'types',
      'packages',
      pkgDirName,
      'src',
      dts
    );
    const cjsPath = resolvePackagePath(pkgDirName, 'dist', 'cjs', dts);
    const esmPath = resolvePackagePath(pkgDirName, 'dist', 'esm', dts);
    const content = fs.readFileSync(dtsPath, { encoding: 'utf8' });
    fs.writeFileSync(cjsPath, content);
    fs.writeFileSync(esmPath, content);
  });
}

// 添加源文件到项目里
async function addSourceFiles(project: Project, pkgSrcDir: string) {
  project.addSourceFileAtPath(resolveProjectPath('env.d.ts'));

  const globSourceFile = '**/*.{js?(x),ts?(x),vue}';
  const filePaths = await glob([globSourceFile], {
    cwd: pkgSrcDir,
    absolute: true,
    onlyFiles: true
  });

  const sourceFiles: SourceFile[] = [];
  await Promise.all([
    ...filePaths.map(async (file) => {
      if (file.endsWith('.vue')) {
        const content = fs.readFileSync(file, { encoding: 'utf8' });
        const hasTsNoCheck = content.includes('@ts-nocheck');
        // vue还是采用vue的编译来编译
        const sfc = vueCompiler.parse(content);
        const { script, scriptSetup } = sfc.descriptor;
        if (script || scriptSetup) {
          let content =
            (hasTsNoCheck ? '// @ts-nocheck\n' : '') + (script?.content ?? '');

          if (scriptSetup) {
            const compiled = vueCompiler.compileScript(sfc.descriptor, {
              id: 'temp'
            });
            content += compiled.content;
          }
          const lang = scriptSetup?.lang || script?.lang || 'js';
          const sourceFile = project.createSourceFile(
            `${path.relative(process.cwd(), file)}.${lang}`,
            content
          );
          sourceFiles.push(sourceFile);
        }
      } else {
        const sourceFile = project.addSourceFileAtPath(file);
        sourceFiles.push(sourceFile);
      }
    })
  ]);

  return sourceFiles;
}

// 生产Typescript类型描述文件
async function generateTypesDefinitions(
  pkgDir: string,
  pkgSrcDir: string,
  outDir: string
) {
  const compilerOptions: CompilerOptions = {
    emitDeclarationOnly: true,
    outDir
  };
  const project = new Project({
    compilerOptions,
    tsConfigFilePath: tsWebBuildConfigPath
  });

  const sourceFiles = await addSourceFiles(project, pkgSrcDir);
  // 就调用emit() 将ts代码编译成js;
  await project.emit({
    emitOnlyDtsFiles: true
  });

  const tasks = sourceFiles.map(async (sourceFile) => {
    const relativePath = path.relative(pkgDir, sourceFile.getFilePath());

    const emitOutput = sourceFile.getEmitOutput();
    const emitFiles = emitOutput.getOutputFiles();
    if (emitFiles.length === 0) {
      throw new Error(`异常文件: ${relativePath}`);
    }

    const subTasks = emitFiles.map(async (outputFile) => {
      const filepath = outputFile.getFilePath();
      fs.mkdirSync(path.dirname(filepath), {
        recursive: true
      });
    });

    await Promise.all(subTasks);
  });
  await Promise.all(tasks);
}

async function build(pkgDirName: string) {
  const outDir = resolveProjectPath('dist', 'types');
  const pkgDir = resolvePackagePath(pkgDirName);
  const pkgSrcDir = resolvePackagePath(pkgDirName, 'src');
  await generateTypesDefinitions(pkgDir, pkgSrcDir, outDir);
  await copyDts(pkgDirName);
}
await build('components');

总结

参考element-plus, 我们采用pnpm + monorepo来进行组件库的搭建,完成了基本组件的编写后,分析element-plus组件的构建流程,我们将组件库的代码进行打包构建,最后输出了相应的产物;