工具函数库介绍
前端工具函数库的构建在现代前端开发中变得越来越重要。这些函数库不仅能够显著提高开发效率,还能减少重复代码的编写,从而降低维护成本。
本文章将基于我的开源项目 flypeng-tool 来深入了解如何构建一款属于自己的前端工具函数库。
感兴趣的童鞋也可参考下它,自己动手搭建一个属于自己的工具函数库。
技术栈抉择
Tsup 构建工具
一开始,我选择了 Rollup 作为构建工具,但后来发现 tsup
内部使用了 esbuild
构建工具,而且提供的配置项更为简洁。于是,我转而选择了 tsup
来进行函数库的开发和打包。
下面的就是 flypeng-tool 的 tsup 的配置文件 tsup.config.ts
。函数库需要打三种形式的包来应对不同场景下的使用,分别是:esm
、cjs
和 iife
。
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,但是它会将仓库设置好的自定义域名设置为空。
后面在它的文档中发现,如果是自定义域名还需要添加一个配置属性 cname
。Add 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 吧 ~