一、开发背景
业务组内前端项目有大量日历黄历相关计算,且需要应用在H5、小程序等诸多平台,这部分算法原先放在js文件中作为模块导出,并没有封装成库。这样做的缺点有:
- API没有统一的文档,使用不方便。
- 需要重复通过调用函数实现业务。
- 不便于管理,算法中有错误没法及时修改到每一个项目。(例如传统万年历与现代农历新年交替日存在分歧等)
尽管网上已经有诸多相关轮子,但是找到一个完全适合自身业务的并不容易,于是我开始考虑是否能自己造个轮子,供自己和组内成员使用提高以开发效率。
二、组件库设计
做前端库开发同我们做项目一样,先定目标再谈方法,而我作为库开发者同时也是库的第一个使用者,我会首先问自己:
这个库能解决什么问题?我希望这个库使用起来是什么样的?
首先库能解决上面三个痛点:
1. 通过编写API文档方便用户使用或查阅,做好版本维护
2. 进一步封装部分实现,减少开发业务时的代码量
3. 通过更新库的版本,在项目中更新引用库版本来实现功能同步
第二点,我希望这个日历库能像 Axios,momentjs 等经典js库一样易用。
对于某一天的日期信息,我希望可以实例化一个类就能获得全部数据,例如:
let cal = new Calendar();
// cal中应该包含了年月日、星期、农历年月日、当天节日等等信息
let cal = new Calendar(2000, 1, 1);
// Calendar应该支持传入参数生成指定日期实例
库的引用方式应该是:
import Calendar from "weli-calendar"; // 农历
import Almanac from "weli-calendar/almanac"; // 黄历
let alc = new Almanac();
// Almanac 作为黄历类,它的实例应该包括宜忌、彭祖百忌等全部黄历数据
根据以上需求可以确定期望的打包结构为:
dist/calendar/index.js
dist/almanac/index.js
在package.json中配置项目入口
{
"main": "/calendar/index.js",
}
就可以实现我们期望的默认引入农历模块
项目结构
项目主要分为calendar(农历相关算法) almanac(黄历相关算法)两部分
以calendar模块为例子,在calendar目录下,index为入口文件,它导出Calendar类,里面定义了一些农历相关属性:
export default class Calendar implements IGregorianCalenar, ILunarCalenar, IFestivalCalendar{
year: gregorianYear;
month: gregorianMonth; // 月 1-12
day: gregorianDay; // 日 1-31
...
lunarYear: lunarYear;
lunarMonth: lunarMonth;
lunarDay: lunarDay;
...
festival?: IFestival[]; // 节日
solarTerm?: string; // 节气
...
}
可以看到这个类实现了三个接口IGregorianCalenar, ILunarCalenar, IFestivalCalendar,而接口的定义全部放在calendar/interfaces.ts中,又以IGregorianCalenar为例:
export default interface IGregorianCalenar {
year: gregorianYear,
month: gregorianMonth, // 月 1-12
day: gregorianDay, // 日 1-31
formatMonth: string, // XX月
formatDay: string, // XX日
constellation: string, // 星座
yearWeek: number, // 今年第几周
week: number, // 星期 1-7
weekCn: string, // "周X"
weekCn2: string, // "星期X"
isCurYear: boolean, // 是今年
isCurMonth: boolean, // 是本月
isCurDay: boolean, // 是今天
diff: number // 距离今天日期差
}
这个接口定义了公历相关的属性,同样的ILunarCalenar定义了农历相关属性,IFestivalCalendar是节日相关。
这样做的目的之一,是因为日历库中,公农历转换是一个非常核心的功能,例如在实现公历转农历的函数中,入参的类型为IGregorianCalenar,返回值的类型为ILunarCalenar,方便区分和理清业务。
calendar/libs中是这个模块的核心算法,它包括公农历转换、查询闰月、查询星座等等的计算函数,但是这些细节我们没有必要全部暴露给使用者,使用者应该只关注Calendar类。
对于某个日期的信息,我们通过实例化Calendar实现,而对于一些重要的工具方法,我们可以将它在libs中实现后挂载在Calendar上作为静态方法供用户使用,例如:
...
static getMonthlyCalendar(beginWeekEn?: 'Mon' | 'Sun'): Calendar[]
static getMonthlyCalendar(y?: gregorianYear | string, m?: gregorianMonth, beginWeekEn: 'Mon' | 'Sun' = 'Sun'): Calendar[] {
...
}
...
getMonthlyCalendar是一个传入年月、返回当月所有日期对象的函数,这个函数就可以作为静态方法挂在到Calendar上,在渲染月历盘等场景使用。
接下来是calendar/type:
export type gregorianYear = number; // 公历年
export type gregorianMonth = number; // 公历月
export type gregorianDay = number; // 公历日
export type lunarYear = number; // 农历年
export type lunarMonth = number; // 农历月
export type lunarDay = number; // 农历日
你可能会好奇他们都是number类型,为什么还要单独定义一次呢?回到上面这个函数:
getMonthlyCalendar(y?: gregorianYear | string, m?: gregorianMonth, beginWeekEn: 'Mon' | 'Sun' = 'Sun')
参数y的类型是gregorianYear,我们可以明白这个参数是公历年,如果这里仅仅写number,我们就还要额外通过注释去声明。
最后calendar/festivalList.ts是节日相关数据。
almanac黄历模块与calendar大致相同,不做赘述。
三、单元测试配置
我们在一般快速的迭代的业务开发中可能并不会做单元测试,因为业务本身快速迭代快速上线的特点,和单元测试的高开发成本有些许矛盾。
但是库的开发和业务有所不同,一个质量过关的库应该是”长期维护,稳定少bug”,因此单元测试在库的开发中是必不可少的。
单元测试使用了jest,配合mockjs用于模拟数据。在ts项目中使用jest还需要安装配置ts-jest和@types/jest。 在jest.config.js中配置preset:
{
preset: "ts-jest"
}
jest为执行文件提供了describe等全局的方法,可以直接使用:
//test.ts
describe("---lib 用例测试---", () => {
// 固定测试几个值
test("toXX", () => {
expect(lib.toXX(1)).toBe("01");
expect(lib.toXX(12)).toBe("12");
expect(lib.toXX('1')).toBe("01");
expect(lib.toXX('12')).toBe("12");
});
})
// lib.toXX是一个把数字类型月日转为XX字符类型的方法
package.json中配置test相关命令:
"scripts": {
...
"test": "jest",
"test-c": "jest --coverage",
"test-s": "jest --watchAll"
}
详细jest配置可参考官网 https://www.jestjs.cn/
四、单元测试技巧
多次循环提高测试准确性
对于一些不能保证一次正确就验证的函数,例如公农历转换等可能某些天正确某些天错误的情况,可以多次循环测试,提高准确性
// 随机取一天查询n天前
test("getPrevDay", () => {
for (let i = 0; i < 200; i++) {
// 测试一天前
let date = moment(Mock.mock('@date'));
let year = +date.year();
let month = +date.month() + 1;
let day = +date.date();
let nextD = date.add(-1, 'day')
expect(lib.getPrevDay(year, month, day)).toEqual({
year: nextD.year(),
month: nextD.month() + 1,
day: nextD.date(),
});
// 测试n天前
let date2 = moment(Mock.mock('@date'));
let year2 = +date2.year();
let month2 = +date2.month() + 1;
let day2 = +date2.date();
let n = Mock.mock({ "number|1-100": 100 }).number // 随机n天前
let futureD = date2.add(-n, 'day');
expect(lib.getPrevDay(year2, month2, day2, n)).toEqual({
year: futureD.year(),
month: futureD.month() + 1,
day: futureD.date(),
});
}
})
实际上,这个看起来有些“蠢”的方法,确实帮助找到了一些问题,例如:
北京时间
夏令时
1986年至1991年,中华人民共和国在全国范围实行了六年夏令时,每年从4月中旬的第一个星期日2时整(北京时间)到9月中旬第一个星期日的凌晨2时整(北京夏令时)。除1986年因是实行夏令时的第一年,从5月4日开始到9月14日结束外,其它年份均按规定的时段施行。夏令时实施期间,将时间向后调快一小时。1992年4月5日后不再实行。
在我通过时间戳计算日期差的时候,偶现部分日期总会出现差一天的情况,经过排查才了解到我国夏令时的政策,及时做兼容处理解决问题。
通过后端接口验证
从设计的角度,通过接口去做单元测试验证并不合理,实际上是一个“自己验证自己”的过程。
但是结合我们实际情况,这种方案是有可行性的,一是当前后端业务相关算法相对稳定,二是将后端代码改造为js成本较高,因此在库开发的初期我决定使用这种方法来做单测,可以快速有效的验证。
// 公历转农历
test("solar2Lunar", () => {
for (let i = 0; i < 100; i++) {
let date = moment(Mock.mock('@date'));
let year = +date.year();
let month = +date.month() + 1;
let day = +date.date();
let { lunarYear, lunarMonth, lunarDay, isLeap } = lib.solar2Lunar(year, month, day)
expect(transferAPI(date.format('YYYYMMDD'), 0, 1)).resolves.toEqual({
date_id: `${lunarYear}${lib.toXX(lunarMonth)}${lib.toXX(lunarDay)}`,
leap_month: isLeap ? 1 : 0
})
}
})
// transferAPI是一个异步方法,调用接口返回数据
// expect验证异步方法要用到resolves,使用详情参考例子与官网
五、打包发布
打包
库的打包一般选择rollup,对于ts需要安装rollup-plugin-typescript2
// rollup.config.prod.js
export default {
input: {
"calendar/index": "src/calendar/index.ts",
"almanac/index": "src/almanac/index.ts",
},
output: {
dir: '.',
format: 'cjs',
sourcemap: false,
chunkFileNames: "[name].js"
...
},
plugins: [
typescript({
tsconfig: './tsconfig.json',
verbosity: 3,
}),
...
],
};
配置打包命令
"scripts": {
"build": "cross-env NODE_ENV=production rollup -c --config rollup.config.prod.js",
"build-dev": "cross-env NODE_ENV=development rollup -c --config rollup.config.prod.js",
"dev": "cross-env NODE_ENV=development rollup -c -w --config rollup.config.dev.js",
...
},
发布
配置上传文件与命令
{
"files": [
"/almanac",
"/calendar",
"festivalList.js",
"/src",
"LICENSE",
"package.json",
"CHANGELOG.md",
"README.md"
],
...
"scripts": {
...
"push": "npm run build && yarn publish --registry=xxxxx",
...
},
}
通过publish命令可以把我们的库发布到npm,这样别人就可以通过npm insatll [包名]或其他方法安装使用我们的库。
如果希望把库发布到私服,需要提前注册登录私服,然后publish之后带上--registry=[私服地址]:
npm publish --registry=[私服地址]
六、总结
库的开发不是一蹴而就的,我们很难在一开始就做好所有的规划,因此我们可以在业务中一边使用一边完善我们的库,从v0.1.0或者v0.0.1开始,直到它进入到一个相对成熟易用的阶段,我们再去发布它的v1.0.0。
目前库开发相关的书籍并不是很多,这里推荐一本《现代JavaScript库开发原理、技术与实战》,其中很多知识技巧值得我们参考。
作者介绍
- 赵智祺 Web前端开发工程师