Skip to content

构建高效前端工具函数库

工具函数库介绍

前端工具函数库的构建在现代前端开发中变得越来越重要。这些函数库不仅能够显著提高开发效率,还能减少重复代码的编写,从而降低维护成本。

本文章将基于我的开源项目 flypeng-tool 来深入了解如何构建一款属于自己的前端工具函数库。

感兴趣的童鞋也可参考下它,自己动手搭建一个属于自己的工具函数库。

技术栈抉择

Tsup 构建工具

一开始,我选择了 Rollup 作为构建工具,但后来发现 tsup 内部使用了 esbuild 构建工具,而且提供的配置项更为简洁。于是,我转而选择了 tsup 来进行函数库的开发和打包。

下面的就是 flypeng-tool 的 tsup 的配置文件 tsup.config.ts。函数库需要打三种形式的包来应对不同场景下的使用,分别是:esmcjsiife

ts
import { Options, defineConfig } from 'tsup';

const currentNodeEnv = process.env.NODE_ENV;
const isProd = currentNodeEnv === 'build';

const commonConfig: Options = {
  minify: isProd,
  sourcemap: !isProd,
  shims: true,
  clean: true,
  dts: true,
};

export default defineConfig([
  {
    format: ['esm', 'cjs', 'iife'],
    entry: ['./packages/Browser/index.ts'],
    outDir: 'dist/browser',
    platform: 'neutral',
    globalName: 'fy',
    outExtension({ format }) {
      if (format === 'iife') return { js: '.browser.js' };
      return { js: `.${format}.js` };
    },
    ...commonConfig,
  },
  {
    format: ['esm', 'cjs'],
    entry: ['./packages/Node/index.ts'],
    outDir: 'dist/node',
    platform: 'node',
    ...commonConfig,
  },
]);

Vitest 测试工具

测试是不可或缺的一部分。我选择了 Vitest 作为单元测试框架,它由 Vite 驱动,提供了对 ESM、Typescript 和 JSX 的开箱即用支持。特别值得一提的是,Vitest 还提供了漂亮的页面与所有测试用例进行交互,即 Vitest UI

虽然说函数库很简单一般不需要啥测试,但谁能保证自己写的代码可以大声说没有Bug呢~。所以测试用例可以不写,但不能代表就可以省略

Vitepress 文档工具

文档对于任何工具库都是至关重要的。我采用了 Vitepress,这是建立在 Vite 之上的 VuePress 的下一代框架。它不仅速度快,而且使用起来非常方便。

以下是使用 @flypeng/tool 搭建文档的效果,可以说的上是相当满意~

Gulp 流程构建工具

Gulp 是一个基于流的前端构建工具,虽然常用于打包第三方库和插件,但现在更多地用于自动化任务。在 flypeng-tool 中,Gulp 的作用是在开发或打包之前进行相关模块入口文件的自动生成、代码格式化和 Cli 自动生成工具函数示例文件。

下面是 gulp 的配置文件 gulpfile.js

js
import { execSync } from 'child_process';

/**
 * 1. 入口、文档侧边栏相关文件生成
 * 2. 代码格式化
 * 3. 函数库 || 文档打包
 */

/**
 * 代码格式化
 */
const codeFormatting = async () => {
  await execSync('npx prettier . --write', { stdio: 'inherit' });
};

/**
 * 构建包相关入口文件
 */
const buildPackage = async () => {
  await execSync('npx esno ./build/packages/index.ts', { stdio: 'inherit' });
};

/**
 * 构建文档相关入口文件
 */
const buildDocs = async () => {
  await execSync('npx esno ./build/docs/Sidebar.ts', { stdio: 'inherit' });
  await execSync('npx esno ./build/docs/Navbar.ts', { stdio: 'inherit' });
  await execSync('npx esno ./build/docs/Version.ts', { stdio: 'inherit' });
};

export const dev = async () => {
  await buildPackage();
  await codeFormatting();
  await execSync('cross-env NODE_ENV=dev tsup --watch', { stdio: 'inherit' });
};

export const build = async () => {
  await buildPackage();
  await codeFormatting();
  await execSync('cross-env NODE_ENV=build tsup', { stdio: 'inherit' });
};

export const docsDev = async () => {
  await buildPackage();
  await buildDocs();
  await codeFormatting();
  await execSync('pnpm run --filter=docs dev', { stdio: 'inherit' });
};

export const docsBuild = async () => {
  await buildPackage();
  await execSync('cross-env NODE_ENV=build tsup', { stdio: 'inherit' });
  await buildDocs();
  await codeFormatting();
  await execSync('pnpm run --filter=docs build', { stdio: 'inherit' });
};

export const docsServe = async () => {
  await execSync('pnpm run --filter=docs serve', { stdio: 'inherit' });
};

export const newFunction = async () => {
  await execSync('npx esno ./build/packages/new.ts', { stdio: 'inherit' });
  await codeFormatting();
};

export const release = async () => {
  await execSync('npx esno ./build/packages/release.ts', { stdio: 'inherit' });
};

与之对应的 package.json 文件

json
{
  "scripts": {
    "dev": "gulp dev",
    "build": "gulp build",
    "docs:dev": "gulp docsDev",
    "docs:build": "gulp docsBuild",
    "docs:serve": "gulp docsServe",
    "new": "gulp newFunction",
    "release": "gulp release"
    // ...
  }
}

相关脚本文件

快速创建模版文件

为了在平时开发中省去频繁创建文件的操作,我编写了一个快速创建模板的脚本,通过脚手架询问的方式来快速创建模板。

以下是快速创建模版的源码,可以参考~

ts
import { readdirSync, writeFileSync, readFileSync, existsSync, rmdirSync } from 'fs';
import { resolve } from 'path';
import { getAbsolutePath, isDirectory, mkdirs } from '../utils';
import { inquireHookName, inquireIsNeed, inquireModuleChoice, inquirePackageChoice } from '../inquirer';

/**
 * 每次创建一个新钩子函数时,运行此脚本文件帮助我们创建
 */

// Browser模块
// export default function useXXXHook() {
//   console.log('function template')
// }

// 测试模块
// import { describe, expect, it } from 'vitest'

// import useXXXHook from '.'

// describe('useXXXHook', () => {
//   it('should be defined', () => {
//     expect(useXXXHook).toBeDefined()
//   })
// })

// Node模块
// export const useXXXHook = () => {}

/**
 * 1. 询问创建的钩子函数时 Browser 还是 Node
 * 2. 让用户填写钩子函数名称和选择模块
 * 3. 如果是 Browser 询问是否需要测试文件
 * 4. 如果是 Browser 询问文档是否创建预览组件 index.vue 文件
 * 5. Browser 创建入口文件 创建测试文件
 */

const createHook = async () => {
  const browserPath = getAbsolutePath('../packages/Browser');
  const nodePath = getAbsolutePath('../packages/Node');
  const docsPath = getAbsolutePath('../docs');
  const packageName = await inquirePackageChoice();
  let modulesList = [];
  if (packageName === 'Browser') {
    // 获取Browser所有模块名称并且添加上Node
    modulesList = readdirSync(browserPath).filter((file) => {
      if (isDirectory(`${browserPath}/${file}`)) return file;
    });
  } else {
    modulesList.push('Node');
  }
  const moduleName = await inquireModuleChoice(modulesList);
  const hookName = await inquireHookName();
  let isNeedTestFile = false;
  let isNeedPreviewFile = false;
  if (packageName === 'Browser') {
    isNeedTestFile = (await inquireIsNeed('Whether test file are required', isNeedTestFile)) as boolean;
    isNeedPreviewFile = (await inquireIsNeed('Whether preview file are required', isNeedPreviewFile)) as boolean;
  }

  let hookPath = '';
  if (packageName === 'Browser') {
    const hookDirPath = resolve(browserPath, `./${moduleName}`, `./${hookName}`);
    hookPath = `${hookDirPath}/index.ts`;
    const hookTestPath = `${hookDirPath}/index.test.ts`;

    const docsDirPath = resolve(docsPath, `./${moduleName}`, `./${hookName}`);
    const docsEntryPath = `${docsDirPath}/index.md`;
    const docsPreviewPath = `${docsDirPath}/index.vue`;

    // 如果存在文件夹则递归删除文件夹中的文件
    if (existsSync(hookDirPath)) {
      rmdirSync(hookDirPath, { recursive: true });
    }
    if (existsSync(docsDirPath)) {
      rmdirSync(docsDirPath, { recursive: true });
    }

    // 创建文件夹
    mkdirs(hookDirPath);
    mkdirs(docsDirPath);

    // 创建文件
    writeFileSync(
      hookPath,
      `export default function ${hookName}() {
	console.log('function template')
}
		`,
    );

    if (isNeedTestFile) {
      writeFileSync(
        hookTestPath,
        `import { describe, expect, it } from 'vitest'
import ${hookName} from '.'
describe('${hookName}', () => {
	it('should be defined', () => {
		expect(${hookName}).toBeDefined()
	})
})
			`,
      );
    }

    writeFileSync(
      docsEntryPath,
      `# ${hookName}

## Introduction

## Basic Usage

## Type Declaration

## Online Demo
		`,
      { encoding: 'utf-8' },
    );

    if (isNeedPreviewFile) {
      writeFileSync(
        docsPreviewPath,
        `<template>
	<div>${hookName}</div>
</template>
<script lang="ts" setup></script>
<style scoped></style>
`,
        { encoding: 'utf-8' },
      );
    }

    // 给入口文件添加导出代码
    const moduleEntryPath = resolve(browserPath, `./${moduleName}`, './index.ts');
    const oldContent = readFileSync(moduleEntryPath, { encoding: 'utf-8' });
    writeFileSync(moduleEntryPath, `${oldContent}\nexport { default as ${hookName} } from './${hookName}'`);
  } else {
    hookPath = resolve(nodePath, './useNodeHook.ts');
    const docsEntryPath = resolve(docsPath, './Node', `${hookName}.md`);
    const oldContent = readFileSync(hookPath, { encoding: 'utf-8' });
    writeFileSync(hookPath, `${oldContent}\nexport const ${hookName} = () => {}`);

    writeFileSync(
      docsEntryPath,
      `# ${hookName}

## Introduction

## Basic Usage

## Type Declaration
		`,
      { encoding: 'utf-8' },
    );
  }
};

createHook();

NPM版本发布

在完成函数库的编写和完善后,我们肯定希望将其发布到 NPM 中,以便在各个项目中使用和持续迭代。

发包的流程包括打包、生成对应版本的标签 tag 和生成 CHANGELOG.md,最后发布到 NPM 上。而这一套流程也可以通过交互式的方式进行。

以下是发包的流程脚本:

ts
import { execSync } from 'child_process';
import { inquireVersion } from '../inquirer';
import { outChalkLog } from '../utils';

// NPM 发包流程文件
// 0. 执行 npm run test 确保所有测试用例通过
// 1. 询问发布啥版本 major minor patch
// 2. 通过 standard-version 修改相关版本信息
// 3. 发包 npm publish
// 4. 提交到远程仓库中并且生成对应版本的tag
async function initRelease() {
  outChalkLog.title('🚀🚀🚀正在准备发布新版本🚀🚀🚀');
  execSync('vitest --watch=false', { stdio: 'inherit' });
  outChalkLog.info('所有测试用例通过');

  execSync('npm run build', { stdio: 'inherit' });
  outChalkLog.info('@flypeng/tool 完成打包');

  execSync('npm run docs:build', { stdio: 'inherit' });
  outChalkLog.info('@flypeng/tool 完成文档相关配置更新');

  const version = await inquireVersion();
  execSync(`standard-version --release-as ${version}`, { stdio: 'inherit' });
  execSync('npm publish', { stdio: 'inherit' });
  outChalkLog.success(`@flypeng/tool-${version} 新版本发布成功`);

  execSync('git push origin main', { stdio: 'inherit' });
  execSync('git push origin --tags', { stdio: 'inherit' });
  outChalkLog.info('代码已提交到远程仓库中');

  outChalkLog.success(`🎉🎉🎉 @flypeng/tool-${version} 新版本发布成功 🎉🎉🎉`);
}

initRelease();

踩坑合集

Github Actions 每次部署时,自定义域名被重置问题

@flypeng/tool 的文档是部署在 Github Pages 当中,然后配置了一个二级域名。

然后每次部署主分支后,都会重新去跑 peaceiris/actions-gh-pages@v3 这个 actions,但是它会将仓库设置好的自定义域名设置为空。

后面在它的文档中发现,如果是自定义域名还需要添加一个配置属性 cnameAdd CNAME file cname。如果不配置这个属性 cname,每次重新部署 Github Pages 中设置的自定义域名就会为空。

yaml
name: Deploy @flypeng/tool docs

jobs:
      ...
      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: docs/.vitepress/dist
          # TIP: 设置 Github Pages 自定义域名
          cname: flypeng-tool.yyblog.top

总结

从零到一地搭建一个属于自己的前端工具函数库并不是一件难事。

通过构建函数库、撰写相关文档以及编写相关脚本,我们可以创建出高质量、易于使用和可维护的函数库,从而提升平时开发中的效率和生产力,这也算是一件挺有意思的事情。

如果这篇文章对你有所帮助的话,也麻烦点击你的小手给个 Star 吧 ~