从封装一个[日期处理]工具到发布为 npm 公共包的全过程

1,157 阅读6分钟

.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}

背景:项目比较多,包含一些公共的代码,如 utils 辅助工具,为避免复制粘贴、版本同步,要将其抽离出来单独作为一个模块来维护 为了方便我们开发,代码总是分分合合,比人纯粹些,分久必合,合久必分。

在这里呢,我将讲解,一步一步的从封装一个工具(日期处理),到发布到 npm 仓库(公共包免费,私有包收费),带你了解整个过程,少踩坑。

准备

环境

平台:Mac os

node:v10.15.3

git:v2.22.1

代码管理:GitHub

编辑器:VS Code

EditorConfig for VS Code:跨编辑器统一项目文件/文本格式

ESLint:eslint 规范插件

Prettier - Code formatter:代码美化

项目依赖

  • @babel/cli
  • @babel/core
  • @babel/preset-env
  • @babel/register
  • mocha
  • eslint
  • eslint-config-prettier
  • eslint-plugin-prettier
  • prettier
  • husky
  • lint-staged

@babelxxx 将 es6+语法编译成 es5,eslintxxxprettier 代码书写规范及美化,huskylint-staged 提交钩子,在提交代码到仓库之前做点事情

项目骨架

.
├── .github               --- github 相关代码
├── es                    --- es6+ 源码
├── lib                   --- es5 源码(由babel编译而来)
├── test                  --- 测试代码
├── .babelrc              --- babel 配置
├── .editorconfig         --- editor 配置
├── .eslintignore         --- 忽略eslint检查配置
├── .eslintrc             --- eslint 配置
├── .gitignore            --- 忽略git提交配置
├── .prettierignore       --- 忽略代码美化配置
├── .prettierrc           --- 代码美化配置
├── LICENSE               --- 许可证
├── README.md             --- 项目介绍(推荐用[readme-md-generator](https://github.com/kefranabg/readme-md-generator)生成)
├── package-lock.json     --- pkg lock文件
└── package.json          --- pkg 文件

现在开始一步步创建出上面的目录结构(通用模版

  • 在 github 上创建一个organization(笔者起了个名 jsany 😎)

  • 在上面创建的组织里创建(new)一个 public 仓库(date)

  • npm上也创建一个organization(笔者起了个名 jsany 😎)这样可以避免 publish 时包名重复的问题

  • 在本地登陆 npm npm login npm_login

  • 在本地工作目录创建一个文件夹 date 并进入 mkdir date && cd date

  • 初始化 git git init

  • 初始化 npm 工程 npm init --scope=jsany npm_init

    {
      "name": "@jsany/date",
      "version": "1.0.0",
      "description": "javascript date small",
      "main": "lib/index.js",
      "scripts": {
        "test": "mocha --require @babel/register"
      },
      "repository": {
        "type": "git",
        "url": "git+https://github.com/jsany/date.git"
      },
      "keywords": [
        "data",
        "js",
        "format",
        "transform"
      ],
      "author": "jiangzhiguo2010",  // 注意这里要和npm账号的用户名一致
      "license": "MIT", // 开源许可证
      "bugs": {
        "url": "https://github.com/jsany/date/issues"
      },
      "homepage": "https://github.com/jsany/date#readme"
    }
    
  • 用 vscode 打开项目(方便操作),在终端执行 code . (这个需要另外配置,想学的可以私聊我)

  • 新建目录 .github,用来存放 github 相关的东西,这里我用来放一个提交规则校验的脚本,详情请看

  • 新建目录 es,用来存放 es6+语法的源码

  • 新建目录 lib,用来存放 es5 语法并支持 commonjs 的源码,不需要编写,由 babel 编译生成

  • 新建目录 test,用来存放测试代码

  • 安装依赖 npm i --save-dev @babel/cli @babel/core @babel/preset-env @babel/register mocha eslint eslint-config-prettier eslint-plugin-prettier husky lint-staged prettier

  • 新建文件 .babelrcbabel 配置

    {
      "presets": [
        [
          "@babel/preset-env",
          {
            "modules": "auto", // 若想通过script标签引入,这里可以使用 umd
            "loose": true,
            "targets": {
              "esmodules": true,
              "node": true
            }
          }
        ]
      ]
    }
    
  • 新建文件 .editorconfig(安装 EditorConfig for VS Code 插件后,也可通过 ⌘+⇧+p 然后输入 Generate .editorconfig 生成),editorconfig 配置,跨编辑器统一项目文件/文本格式

    root = true
    
    [*]
    indent_style = space
    indent_size = 2
    end_of_line = lf
    charset = utf-8
    trim_trailing_whitespace = false
    insert_final_newline = false
    
  • 新建文件 .eslintignoreeslint 配置,忽略检查

    node_modules/
    
  • 新建文件 .eslintrceslint 配置

    {
      "env": {
        "browser": true,
        "es6": true,
        "node": true
      },
      "plugins": ["prettier"],
      "extends": ["eslint:recommended", "plugin:prettier/recommended"],
      "globals": {
        "Atomics": "readonly",
        "SharedArrayBuffer": "readonly"
      },
      "parserOptions": {
        "ecmaVersion": 2018,
        "sourceType": "module"
      },
      "rules": {
        "no-console": "off",
        "prettier/prettier": "error"
      }
    }
    
  • 新建文件 .gitignore忽略 git 提交配置

    # dependencies
    /node_modules
    /npm-debug.log*
    /yarn-error.log
    /yarn.lock
    /package-lock.json
    
    .DS_Store
    .idea/
    .vscode
    
    
  • 新建文件 .prettierignore忽略代码美化配置

    **/*.svg
    **/*.ejs
    **/*.html
    
  • 新建文件 .prettierrc代码美化配置

    {
      "singleQuote": true,
      "trailingComma": "es5",
      "printWidth": 100
    }
    

经过上面的步骤,package.json大体上是这样子:

{
  "name": "@jsany/date",
  "version": "1.0.5", // 包版本,每发布一次,需更新
  "description": "javascript date small es5 es6+",
  "main": "lib/index.js", // commonjs 入口文件,使用 require 语法引入
  "module": "es/index.js", // esmodules 入口文件,使用 import/require 语法引入,支持tree shaking 优化
  "files": ["lib", "es"], // 这个files用来指定需要发布的文件(将无用的文件剔除掉,减少体积,下载快,也可以在`.npmignore`文件中指定需要剔除的文件)
  "scripts": {
    "test": "mocha --require @babel/register", // 测试命令
    "compile": "babel es --out-dir lib" // babel编译命令
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/jsany/date.git"
  },
  "keywords": ["data", "js", "format", "transform"],
  "author": "jiangzhiguo2010",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/jsany/date/issues"
  },
  "homepage": "https://github.com/jsany/date",
  "devDependencies": {
    "@babel/cli": "^7.5.5",
    "@babel/core": "^7.5.5",
    "@babel/preset-env": "^7.5.5",
    "@babel/register": "^7.5.5",
    "mocha": "^6.2.0",
    "eslint": "^5.16.0",
    "eslint-config-prettier": "^4.3.0",
    "eslint-plugin-prettier": "^3.1.0",
    "husky": "^2.4.1",
    "lint-staged": "^8.2.0",
    "prettier": "^1.18.2"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "commit-msg": "node .github/verifyCommitMsg"
    }
  },
  "lint-staged": {
    "*.{js,css,json,md}": ["prettier --write", "git add"]
  },
  "directories": {
    "test": "test"
  },
  "dependencies": {}
}

开始编写 date 源码

date 提供格式化,时区转换,获取时间戳等功能

格式化功能

思路:(日期,格式)=>符合预期格式的日期,格式可以通过正则匹配来返回固定的格式,按照这个来实现一下

新建 dateFormat.js

/**
 * @description 格式化日期
 * @param {(object|string)} date - 日期对象/字符串
 * @param {string} mask - 日期格式,默认:mask='yyyy-MM-dd HH:mm:ss'
 * @returns {string} 返回格式化后的日期
 */
const dateFormat = (date, mask = 'yyyy-MM-dd HH:mm:ss') => {
  const d = typeof date !== 'object' ? new Date(date) : date;
  if (!d.getTime()) {
    throw new MyError({ code: '000', msg: ErrorCode['000'] });
  }
  const zeroize = (value, length = 2) => {
    value = String(value);
    let zeros = '';
    for (let i = 0, len = length - value.length; i < len; i++) {
      zeros += '0';
    }
    return zeros + value;
  };
  return mask.replace(
    /"[^"]*"|'[^']*'|\b(?:d{1,4}|m{1,4}|yy(?:yy)?|([hHMstT])\1?|[lLZ])\b/gi,
    function($0) {
      switch ($0) {
        case 'd':
          return d.getDate();
        case 'dd':
          return zeroize(d.getDate());
        case 'ddd':
          return ['Sun', 'Mon', 'Tue', 'Wed', 'Thr', 'Fri', 'Sat'][d.getDay()];
        case 'dddd':
          return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][
            d.getDay()
          ];
        case 'M':
          return d.getMonth() + 1;
        case 'MM':
          return zeroize(d.getMonth() + 1);
        case 'MMM':
          return [
            'Jan',
            'Feb',
            'Mar',
            'Apr',
            'May',
            'Jun',
            'Jul',
            'Aug',
            'Sep',
            'Oct',
            'Nov',
            'Dec',
          ][d.getMonth()];
        case 'MMMM':
          return [
            'January',
            'February',
            'March',
            'April',
            'May',
            'June',
            'July',
            'August',
            'September',
            'October',
            'November',
            'December',
          ][d.getMonth()];
        case 'yy':
          return String(d.getFullYear()).substr(2);
        case 'yyyy':
          return d.getFullYear();
        case 'h':
          return d.getHours() % 12 || 12;
        case 'hh':
          return zeroize(d.getHours() % 12 || 12);
        case 'H':
          return d.getHours();
        case 'HH':
          return zeroize(d.getHours());
        case 'm':
          return d.getMinutes();
        case 'mm':
          return zeroize(d.getMinutes());
        case 's':
          return d.getSeconds();
        case 'ss':
          return zeroize(d.getSeconds());
        case 'l':
          return zeroize(d.getMilliseconds(), 3);
        case 'L':
          var m = d.getMilliseconds();
          if (m > 99) m = Math.round(m / 10);
          return zeroize(m);
        case 'tt':
          return d.getHours() < 12 ? 'am' : 'pm';
        case 'TT':
          return d.getHours() < 12 ? 'AM' : 'PM';
        case 'Z':
          return d.toUTCString().match(/[A-Z]+$/);
        // Return quoted strings with the surrounding quotes removed
        default:
          return $0.substr(1, $0.length - 2);
      }
    }
  );
};
export default dateFormat;

获取 utc 时间戳

思路:(utc 日期)=> utc 时间戳,通过 getTime 得到当前时区的时间戳,getTimezoneOffset 得到当前时区偏移量,二者差值即 utc 时间戳

新建 utcTimestamp.js

/**
 * @description 获取utc时间戳
 * @param {string} date - utc日期对象/字符串,默认:当前时间
 * @returns {number} 返回utc时间戳
 */
const UTCTimestamp = (date = new Date()) => {
  return new Date(date).getTime() - new Date().getTimezoneOffset() * 60 * 1000;
};
export default UTCTimestamp;

utc 时间转任意时区时间

思路:(utc 日期,时区偏移量,格式)=>任意时区时间,通过 getTime 得到时间戳,减去输入的偏移量,即任意时区时间,按照这个来实现一下

新建 utc2target.js

/**
 * @description utc时间转目标时区的时间,默认为utc时间转本地时间
 * @param {object|string} date - utc时间,日期对象/字符串
 * @param {number} timezone - 目标时区,默认:本地时区timezone=-480(中国时区+0800)
 * @param {*} mask - 日期格式,默认:mask='yyyy-MM-dd HH:mm:ss'
 * @returns {string} 返回目标时区的时间
 */
const UTC2Target = (
  date,
  timezone = new Date().getTimezoneOffset(),
  mask = 'yyyy-MM-dd HH:mm:ss'
) => {
  const utcTimestamp = new Date(date).getTime();
  if (!utcTimestamp) {
    throw new MyError({ code: '000', msg: ErrorCode['000'] });
  }
  date = dateFormat(new Date(utcTimestamp - timezone * 60 * 1000), mask);
  return date;
};

export default UTC2Target;

任意时区时间转 utc 时间

思路:(任意时区日期,时区偏移量,格式)=>utc 时间,通过 getTime 得到当前时区时间戳,加上输入的偏移量,即 utc 时间,按照这个来实现一下

新建 target2utc.js

/**
 * @description 目标时区的时间转utc时间,默认为本地时间转utc时间
 * @param {object|string} date - 目标时区时间,日期对象/字符串
 * @param {number} timezone - 目标时区,默认:本地时区timezone=-480(中国时区+0800)
 * @param {*} mask - 日期格式,默认:mask='yyyy-MM-dd HH:mm:ss'
 * @returns {string} 返回目标时区的utc时间
 */
const Target2UTC = (
  date,
  timezone = new Date().getTimezoneOffset(),
  mask = 'yyyy-MM-dd HH:mm:ss'
) => {
  let targetTimestamp = new Date(date).getTime();
  if (!targetTimestamp) {
    throw new MyError({ code: '000', msg: ErrorCode['000'] });
  }
  date = dateFormat(new Date(targetTimestamp + timezone * 60 * 1000), mask);
  return date;
};

export default Target2UTC;

补充一下上面用到的工具函数/模块:

  • ./helper/errCode.js

    export default {
      '000': 'Invalid Date',
    };
    
  • ./helper/index.js

    /**
     * @description 获取数据的具体类型
     * @param {any} o - 要判断的数据
     * @returns {string} - 返回该数据的具体类型
     */
    export const getDataType = o => {
      // 映射数据类型
      const map2DataType = {
        '[object String]': 'String',
        '[object Number]': 'Number',
        '[object Undefined]': 'Undefined',
        '[object Boolean]': 'Boolean',
        '[object Array]': 'Array',
        '[object Function]': 'Function',
        '[object Object]': 'Object',
        '[object Symbol]': 'Symbol',
        '[object Set]': 'Set',
        '[object Map]': 'Map',
        '[object WeakSet]': 'WeakSet',
        '[object WeakMap]': 'WeakMap',
        '[object Null]': 'Null',
        '[object Promise]': 'Promise',
        '[object NodeList]': 'NodeList',
        '[object Date]': 'Date',
        '[object FormData]': 'FormData',
      };
      o = Object.prototype.toString.call(o);
      if (map2DataType[o]) {
        return map2DataType[o];
      } else {
        return o.replace(/^\[object\s(.*)\]$/, '$1');
      }
    };
    
    /**
     * @description 扩展Error
     */
    export class MyError extends Error {
      constructor(props) {
        super(props);
        this.code = props.code || 0;
        this.msg = props.msg || 'default msg';
        this.name = 'MyError';
        this.message = JSON.stringify(props);
      }
    }
    

新建文件 index.js 将方法集中导出

export { default as dateFormat } from './dateFormat';
export { default as UTCTimestamp } from './utcTimestamp';
export { default as UTC2Target } from './utc2target';
export { default as Target2UTC } from './target2utc';

测试 date 功能(mocha

mocha 不支持 esmodules,因此要用 babel 进行编译,在 cli 增加参数 --require @babel/register 即可

es6+ 测试(unit)

在 test 文件夹下新建文件 index.es.test.js

import { UTCTimestamp, UTC2Target, Target2UTC } from '../es/index';
const assert = require('assert');

const bj = '2019-01-01 08:00:00';
const ist = '2019-01-01 05:30:00';
const utc = '2019-01-01 00:00:00';
const utc_unix = 1546300800000;

describe('#@jsany/date(es)', () => {
  describe('#UTCTimestamp', () => {
    it('UTCTimestamp() should return true', () => {
      return assert.strictEqual(UTCTimestamp(utc, -480), utc_unix);
    });
  });
  describe('#UTC2Target', () => {
    it('UTC2Target() should return true', () => {
      return assert.strictEqual(UTC2Target(utc, -480), bj);
    });
    it('UTC2Target() should return true', () => {
      return assert.strictEqual(UTC2Target(utc, -330), ist);
    });
  });
  describe('#Target2UTC', () => {
    it('Target2UTC() should return true', () => {
      return assert.strictEqual(Target2UTC(bj, -480), utc);
    });
    it('Target2UTC() should return true', () => {
      return assert.strictEqual(Target2UTC(ist, -330), utc);
    });
  });
});

es5 测试(unit)

首先运行编译命令 npm run compile

然后在 test 文件夹下新建文件 index.lib.test.js

const { UTCTimestamp, UTC2Target, Target2UTC } = require('../lib/index');
const assert = require('assert');

const bj = '2019-01-01 08:00:00';
const ist = '2019-01-01 05:30:00';
const utc = '2019-01-01 00:00:00';
const utc_unix = 1546300800000;

describe('#@jsany/date(lib)', () => {
  describe('#UTCTimestamp', () => {
    it('UTCTimestamp() should return true', () => {
      return assert.strictEqual(UTCTimestamp(utc, -480), utc_unix);
    });
  });
  describe('#UTC2Target', () => {
    it('UTC2Target() should return true', () => {
      return assert.strictEqual(UTC2Target(utc, -480), bj);
    });
    it('UTC2Target() should return true', () => {
      return assert.strictEqual(UTC2Target(utc, -330), ist);
    });
  });
  describe('#Target2UTC', () => {
    it('Target2UTC() should return true', () => {
      return assert.strictEqual(Target2UTC(bj, -480), utc);
    });
    it('Target2UTC() should return true', () => {
      return assert.strictEqual(Target2UTC(ist, -330), utc);
    });
  });
});

运行测试:npm run test

npm_test

本地npm包测试(npm link)

首先,新建一个文件夹,作为测试npm包的新工程,可以与npm包工程(date)同级目录

cd .. && mkdir dateTest && cd dateTest

然后建立与date的npm软链

npm link ../date

此时,在dateTest文件夹下就有了date的npm依赖,可以查看下node_modules

npm link

现在就可以新建js文件进行导入测试了

test

编写 README

运行 npx readme-md-generator,创建 README.md 模版文件,然后补全

tips:这种npm_version小图标可以在shields.io生成

提交代码至 github

  • git remote add origin git@github.com:jsany/date.git
  • git push -u origin master

发布

首先确认自己已登陆

npm_whoami

检查 package.json 文件,记住每次更改发布,都应该是一个新的版本,没问题后开始发布(公开包 --access=public), 由于咱们是在一个组织(organization)下发包,所以不用担心 包名会重复的问题了,无需用 npm view 做检查了

npm publish --access=public

npm_publish

查看源码

【参考】:

  1. www.npmjs.cn
  2. babeljs.io/docs/en
  3. eslint.org
  4. git-scm.com
  5. prettier.io
  6. mochajs.org
  7. shields.io

===🧐🧐 文中不足,欢迎指正 🤪🤪===