作者:易师傅 、github
声明:文章为稀土AdminJS技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
前言
大家好,我是易师傅,一个专门搞前端的搬(touch)砖(fish)师傅 ~
在上两篇文章中《如何去搞基建》和《如何去搞前端团队规范》中主要给大家介绍了 前端基建
和前端规范
的理论篇;
下面就给大家带来代码实战篇 — 《如何去搞企业工具库》;
至于为什么搞工具库,可以看看 前端基建这里的介绍
长话短说,咱们直接开干 ~
快速体验:
-
工具库模板地址:链接
-
Github 开源地址:链接
一、初始化项目
因为我们的目的是想做成一个 monorepo 仓库,而为什么用 monorepo 仓库,一句话解释就是想把 多个项目
放在 一个仓库中
管理,不懂的可自行搜索或参考《现代前端工程为什么越来越离不开 Monorepo》;
而使用 monorepo 仓库使用较多的一般就是yarn workspace
和 lerna 包管理工具
之类的,但是我们要使用的是 pnpm workspace
,至于为啥?
一句话解释就是:速度快,节省磁盘,安全性高;
1. 初始化
pnpm init
2. 创建 pnpm-workspace.yaml
touch pnpm-workspace.yaml
3. 修改 pnpm-workspace.yaml
packages:
- packages/*
- playground
- docs
4. 新增 packages/core
目录
mkdir packages/core
cd packages/core
pnpm init
5. 新增 typescript 依赖
pnpm i typescript @types/node -Dw
# 初始化
npx tsc --init
到这里一个简单的 pnpm monorepo 仓库就搭建的差不多了;
你的目录应该就是如下图这样子的(其中 LICENSE 和 README.md 为手动创建):
好家伙,一顿操作下来是不是很简单呢 ~
二、pnpm workspace 指南
1. 安装根目录依赖
pnpm
提供了 -w, --workspace-root 参数,可以将依赖包安装到工程的根目录下,作为所有 packages 的公共依赖。
pnpm i typescript -w
2. 安装开发依赖,需加上 -D
参数
pnpm i typescript -Dw
3. 单独给 packages/core
安装指定依赖
pnpm
提供了 --filter 参数;
可以对指定的 packages/core
进行操作,其中 --filter
跟着的参数是 <package_selector>
也就是初始化的 packages/core
中 package.json
的 name
字段:
# 使用
pnpm add <package_name> --filter <package_selector>
# 例子
pnpm add typescript -D --filter @vmejs/core
4. 如何运行 packages/core
中的 scripts
脚本
因为我们想直接运行指定某个包 packages/*
下的某个脚本,那么你可以这么做:
# 运行 @vmejs/core 包中的 dev 命令
pnpm dev --filter @vmejs/core
# 运行 @vmejs/core 包中的 build 命令
pnpm build --filter @vmejs/core
5. 各个 packages/*
模块包间的相互依赖
在实际开发中,不可能只存在一个 packages/core
包,可能还有 packages/shared
等,那么我们如何在 packages/core
中依赖 packages/shared
呢?
直接运行:
pnpm install @vmejs/shared -r --filter @vmejs/core
但是安装后的包会带上具体版本,这里是不推荐的,所以需要我们手动更改 packages/core
目录 package.json
下的 "@vmejs/shared": "workspace:^1.0.0”
为 "@vmejs/shared": "workspace:*"
,如下图
"@vmejs/shared": "workspace:*"
当然,pnpm workspace
的使用远不止于此,但是对于开发咱们的工具库足矣;
如若您想了解更多,可前往官网查看更多
三、配置 eslint + prettier + husky + commitlint
eslint + prettier:代码质量
与 代码风格
是我们必须要做的项任务,正所谓没有规矩不成方圆;
husky + commitlint:git hooks
和 提交检测
我们也是必须要集成的;
如果你还有疑问,可以看看我的上一篇文章《前端规范都有哪些》;
1. 配置 eslint:
-
安装
pnpm i eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin -Dw
-
新建
.eslintrc
和.eslintignore
文件编辑
.eslintrc
文件:{ "root": true, "env": { "browser": true, "es2021": true, "es6": true, "node": true }, "parser": "@typescript-eslint/parser", "parserOptions": { "sourceType": "module", "ecmaVersion": 12, "ecmaFeatures": { "jsx": true, "tsx": true } }, "plugins": ["@typescript-eslint"], "rules": { "no-console": "error", "no-debugger": "error" } }
-
在
package.json
中script
添加脚本"lint": "eslint . --ext .vue,.js,.ts,.jsx,.tsx,.json --max-warnings 0 --cache", "lint:fix": "pnpm run lint --fix",
2. 配置 prettier:
-
安装
pnpm i prettier eslint-config-prettier eslint-plugin-prettier -Dw
-
新建 .prettierrc(非必须,可不填)
{ "bracketSpacing": true, "jsxBracketSameLine": true, "jsxSingleQuote": false, "printWidth": 140, "semi": true, "useTabs": false, "singleQuote": true, "tabWidth": 2, "endOfLine": "auto", "trailingComma": "all" }
-
在
package.json
中script
添加脚本"format": "prettier --write --cache .",
到这里你可以自己新建一个 test.ts
去测试是否生效;
3. 配置 husky:
-
安装
pnpm i husky lint-staged -Dw
-
在
package.json
中script
添加脚本script:{ "prepare": "husky install", }, "lint-staged": { "*.{vue,js,ts,jsx,tsx,md,json}": [ "pnpm run lint", "pnpm run format" ] }
-
初始化
husky
(按顺序运行以下命令)# 按顺序运行以下命令 npx husky install npx husky add .husky/pre-commit "npx --no-install lint-staged"
4. 配置 commitlint
-
安装
pnpm i @commitlint/config-conventional @commitlint/cli -Dw
-
创建 commitlint.config.ts
module.exports = { extends: ['@commitlint/config-conventional'], };
-
运行
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'
好的,到这里我们就基本安装完成了,下面我们一起 git push 验证一下吧!
5. 验证
-
新建
.gitignore
文件.DS_Store .history .vscode .idea .eslintcache .pnpm-debug.log *.local dist node_modules types coverage
-
git 提交
git add . && git commit -m "init(init): init"
-
执行效果如下图
好的呢,到这里就搭建好基本架子了,下面就开始动手吧 ~
四、添加共享函数集合(@vmejs/shared)
@vmejs/shared
是一个共享的函数包,包含了其他包所使用的的一些公共方法;
1. 初始化 packages/shared
包(已有可忽略)
# 1.新建
mkdir packages/shared
# 2.初始化
cd packages/shared && pnpm init
2. 添加常见的共享工具函数:
-
新建
packages/shared/is
目录 -
新建
packages/shared/is/index.ts
(不细诉详细过程):export const isClient = typeof window !== 'undefined' export const isDef = <T = any>(val?: T): val is T => typeof val !== 'undefined' export const assert = (condition: boolean, ...infos: any[]) => { if (!condition) console.warn(...infos) } const toString = Object.prototype.toString export const isBoolean = (val: any): val is boolean => typeof val === 'boolean' export const isFunction = <T extends Function> (val: any): val is T => typeof val === 'function' export const isNumber = (val: any): val is number => typeof val === 'number' export const isString = (val: unknown): val is string => typeof val === 'string' export const isObject = (val: any): val is object => toString.call(val) === '[object Object]' export const isWindow = (val: any): val is Window => typeof window !== 'undefined' && toString.call(val) === '[object Window]' export const now = () => Date.now() export const timestamp = () => +Date.now() export const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n)) export const noop = () => {} export const rand = (min: number, max: number) => { min = Math.ceil(min) max = Math.floor(max) return Math.floor(Math.random() * (max - min + 1)) + min } export const isIOS = /* #__PURE__ */ isClient && window?.navigator?.userAgent && /iP(ad|hone|od)/.test(window.navigator.userAgent) export const hasOwn = <T extends object, K extends keyof T>(val: T, key: K): key is K => Object.prototype.hasOwnProperty.call(val, key)
3. 导出
- 新建
packages/shared/index.ts
文件export * from './is';
4. 在 packages/core
包中使用
import { isString } from '@vmejs/shared';
五、开始第一个函数(@vmejs/core)
1. 初始化 packages/core
包(已有可忽略)
# 1.新建
mkdir packages/core
# 2.初始化
cd packages/core && pnpm init
2. 新增获取当前浏览器设备信息函数
-
新增
packages/core/getDevice/index.ts
目录mkdir getDevice && touch index.ts
-
编写代码(不细述实现过程)
import { isString } from '@vmejs/shared' export const DEVICES = [ { regs: [ /\b(sch-i[89]0\d|shw-m380s|sm-[pt]\w{2,4}|gt-[pn]\d{2,4}|sgh-t8[56]9|nexus 10)/i, /\b((?:s[cgp]h|gt|sm)-\w+|galaxy nexus)/i, /samsung[- ]([-\w]+)/i, /sec-(sgh\w+)/i, ], vendor: 'Samsung', }, { regs: [ /\((ip(?:hone|od)[\w ]*);/i, /\((ipad);[-\w\),; ]+apple/i, /applecoremedia\/[\w\.]+ \((ipad)/i, /\b(ipad)\d\d?,\d\d?[;\]].+ios/i, ], vendor: 'Apple', }, { regs: [ /(pixel c)\b/i, /droid.+; (pixel[\daxl ]{0,6})(?: bui|\))/i, ], vendor: 'Google', }, { regs: [ /\b((?:ag[rs][23]?|bah2?|sht?|btv)-a?[lw]\d{2})\b(?!.+d\/s)/i, /(?:huawei|honor)([-\w ]+)[;\)]/i, /\b(nexus 6p|\w{2,4}e?-[atu]?[ln][\dx][012359c][adn]?)\b(?!.+d\/s)/i, ], vendor: 'Huawei', }, { regs: [ /\b(poco[\w ]+)(?: bui|\))/i, // Xiaomi POCO /\b; (\w+) build\/hm\1/i, // Xiaomi Hongmi 'numeric' models /\b(hm[-_ ]?note?[_ ]?(?:\d\w)?) bui/i, // Xiaomi Hongmi /\b(redmi[\-_ ]?(?:note|k)?[\w_ ]+)(?: bui|\))/i, // Xiaomi Redmi /\b(mi[-_ ]?(?:a\d|one|one[_ ]plus|note lte|max|cc)?[_ ]?(?:\d?\w?)[_ ]?(?:plus|se|lite)?)(?: bui|\))/i, // Xiaomi Mi /\b(mi[-_ ]?(?:pad)(?:[\w_ ]+))(?: bui|\))/i, // Mi Pad tablets ], vendor: 'Xiaomi', }, { regs: [ /; (\w+) bui.+ oppo/i, /\b(cph[12]\d{3}|p(?:af|c[al]|d\w|e[ar])[mt]\d0|x9007|a101op)\b/i, ], vendor: 'OPPO', }, { regs: [ /vivo (\w+)(?: bui|\))/i, /\b(v[12]\d{3}\w?[at])(?: bui|;)/i, ], vendor: 'Vivo', }, { regs: [ /(Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i, ], vendor: 'other', }, ] /** * 获取设备类型与供应商 * @param ua window.navigator.userAgent * @returns { model: '', vendor: '' } */ export const getDevice = (ua?: string) => { const device = { model: '', vendor: '', } if (!isString(ua)) { // node runtimes env if (global) return device ua = window.navigator.userAgent } device.model = 'pc' device.vendor = 'other' for (let i = 0; i <= DEVICES.length; i++) { if (!DEVICES[i]) break const { regs, vendor } = DEVICES[i] const findVal = regs.find(item => item.exec(ua as string)) if (findVal) { device.model = 'mobile' device.vendor = vendor break } } return device }
3. 导出:新建 packages/core/index.ts
文件
export * from './getDevice';
那写到这里其实咱们的第一个工具函数就完成了~
看了之后是不是觉得超简单呢?
是的,没错,的确简单!
但是我们在上篇文章《非大厂的我们,如何去卷一套标准的前端团队规范?》中有说明,前端测试的重要性;
所以咱们不可避免的要使用单元测试
来对我们的工具函数来进行 自动化测试
保证它的完整度;
六、单元测试
1. 测试工具的选择:
mocha
:Mocha 是一个功能丰富的 JavaScript 测试框架;jest
:facebook 出的一款 JavaScript 自动化测试框架,专注于简单性。vitest
:尤大团队打造,一个由 Vite 打造的单元测试框架,而且它很快!
无可厚非,我选择 vitest 来进行单元测试 ~
当然,你对其它测试框架比较熟悉,你亦可以选择,选择权在你 ~
2. 安装 vitest
pnpm i vitest -Dw
3. 新建 vitest.config.ts
import { resolve } from 'path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true
},
resolve: {
alias: {
'@vmejs/shared': resolve(__dirname, 'packages/shared/index.ts'),
'@vmejs/core': resolve(__dirname, 'packages/core/index.ts'),
},
},
})
4. 配置运行脚本:package.json
"script": {
...
"test": "vitest test", // 执行测试
"coverage": "vitest run --coverage" // 执行测试覆盖率,需要安装 @vitest/coverage-c8
...
}
5. 编写 packages/core/getDevice
函数的测试用例
-
新建
packages/core/getDevice/index.test.ts
文件import { expect, describe, it } from 'vitest' import { getDevice } from '.' describe('device test', () => { it('The device should return {}', () => { const browser = getDevice() expect(browser).toEqual({ model: '', vendor: '', }) }) it('The device should return mobile/Samsung', () => { const uaStr = 'Mozilla/5.0 (Linux; U; Android 2.3.5; de-de; SAMSUNG GT-S5830/S5830BUKS2 Build/GINGERBREAD) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1' const device = getDevice(uaStr) expect(device).toEqual({ model: 'mobile', vendor: 'Samsung', }) }) it('The device should return mobile/Apple', () => { const uaStr = 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1' const device = getDevice(uaStr) expect(device).toEqual({ model: 'mobile', vendor: 'Apple', }) }) it('The device should return mobile/Apple', () => { const uaStr = 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1' const device = getDevice(uaStr) expect(device).toEqual({ model: 'mobile', vendor: 'Apple', }) }) it('The device should return mobile/Huawei', () => { const uaStr = 'Mozilla/5.0 (Linux; U; Android 7.0; zh-cn; KNT-AL20 Build/HUAWEIKNT-AL20) AppleWebKit/537.36 (KHTML, like Gecko) MQQBrowser/7.3 Chrome/37.0.0.0 Mobile Safari/537.36' const device = getDevice(uaStr) expect(device).toEqual({ model: 'mobile', vendor: 'Huawei', }) }) it('The device should return mobile/other', () => { const uaStr = 'Mozilla/5.0 (Linux; U; Android 2.3.5; en-gb; HTC Desire HD A9191 Build/GRJ90) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1' const device = getDevice(uaStr) expect(device).toEqual({ model: 'mobile', vendor: 'other', }) }) it('The device should return pc/other', () => { const uaStr = 'Mozilla/5.0 (Windows; U; Windows NT 5.1; de-CH) AppleWebKit/523.15 (KHTML, like Gecko, Safari/419.3) Arora/0.2' const device = getDevice(uaStr) expect(device).toEqual({ model: 'pc', vendor: 'other', }) }) })
6. 执行 pnpm test
如下图:
7. 执行 pnpm coverage
:测试覆盖率
-
安装
@vitest/coverage-c8
pnpm i @vitest/coverage-c8 -Dw
-
执行结果:
8. 其它测试工具的搭配(文章篇幅原因就不细述):
happy-dom
:在测试环境模拟 Web 浏览器,以便执行自动化测试脚本;@vitest/coverage-c8
:展示测试覆盖率;
到这里其实就已经可以大致的去使用了;
但是我们试想一下,要想做一个适合企业或者开源的一个工具库,那么配套的文档是必不可少的;
毕竟你写的代码不知道如何去使用那将完全失去了它的价值,所以项目文档是标配;
七、搭建文档
搭建文档的方法有很多,有开源的库
,也有一些现成的在线工具
,或者你有时间去自研
也是一个不错的选择;
为了专注于代码的实现层面,咱们可以使用现有的文档生成框架,那么 Vitepress
就是一个很不错的选择;
由于 Vitepress
是基于 Vite
的,所以它也很好的继承了 Bundless
特性,开发的代码能以秒级
速度在文档中看到运行效果,完全可以充当调试工具来使用。
1. 安装
pnpm i vitepress -Dw
2. 配置 vitepress.config.ts
:默认不需要配置
3. 配置运行脚本:package.json
"script": {
...
"docs:dev": "vitepress dev packages",
"docs:build": "vitepress build packages",
...
}
4. 运行 pnpm docs:dev
后如下图:
因为还未做任何配置,所以会是 404 页面
5. 基本配置:新建 packages/index.md
文件
---
layout: home
sidebar: false
title: vmejs
titleTemplate: 一个疯狂的开源前端工具库
hero:
name: vmejs
text: 一个疯狂的开源前端工具库
tagline: