前端库开发经验小结

一、开发背景

业务组内前端项目有大量日历黄历相关计算,且需要应用在H5、小程序等诸多平台,这部分算法原先放在js文件中作为模块导出,并没有封装成库。这样做的缺点有:

  1. API没有统一的文档,使用不方便。
  2. 需要重复通过调用函数实现业务。
  3. 不便于管理,算法中有错误没法及时修改到每一个项目。(例如传统万年历与现代农历新年交替日存在分歧等)

尽管网上已经有诸多相关轮子,但是找到一个完全适合自身业务的并不容易,于是我开始考虑是否能自己造个轮子,供自己和组内成员使用提高以开发效率。

二、组件库设计

做前端库开发同我们做项目一样,先定目标再谈方法,而我作为库开发者同时也是库的第一个使用者,我会首先问自己:

这个库能解决什么问题?我希望这个库使用起来是什么样的?

首先库能解决上面三个痛点: 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",
}

就可以实现我们期望的默认引入农历模块

项目结构

https://mixmedia.rili.cn/5193b750-9583-4e52-9f9a-9f7c7a703fed.png

项目主要分为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", () =&gt; {
        for (let i = 0; i &lt; 100; i++) {
            let date = moment(Mock.mock(&#039;@date&#039;));
            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(&#039;YYYYMMDD&#039;), 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 &amp;&amp; yarn publish --registry=xxxxx",
    ...
  },
}

通过publish命令可以把我们的库发布到npm,这样别人就可以通过npm insatll [包名]或其他方法安装使用我们的库。

如果希望把库发布到私服,需要提前注册登录私服,然后publish之后带上--registry=[私服地址]:

npm publish --registry=[私服地址]  

六、总结

库的开发不是一蹴而就的,我们很难在一开始就做好所有的规划,因此我们可以在业务中一边使用一边完善我们的库,从v0.1.0或者v0.0.1开始,直到它进入到一个相对成熟易用的阶段,我们再去发布它的v1.0.0。

目前库开发相关的书籍并不是很多,这里推荐一本《现代JavaScript库开发原理、技术与实战》,其中很多知识技巧值得我们参考。

作者介绍

  • 赵智祺 Web前端开发工程师

微鲤技术团队

微鲤技术团队承担了中华万年历、Maybe、蘑菇语音、微鲤游戏高达3亿用户的产品研发工作,并构建了完备的大数据平台、基础研发框架、基础运维设施。践行数据驱动理念,相信技术改变世界。