Skip to content
目录

插件的开发和使用

在构建 Vue 项目的过程中,离不开各种开箱即用的插件支持,用以快速完成需求,避免自己造轮子。

在 Vue 项目里,可以使用针对 Vue 定制开发的专属插件,也可以使用无框架依赖的通用 JS 插件,插件的表现形式也是丰富多彩,既可以是功能的实现,也可以是组件的封装,本章将从插件的使用到亲自开发一个小插件的过程,逐一讲解。

温馨提示

这是笔者写于 2024.02.20 的补充提示,为读者解答为何前面的章节都未提及 script-setup 组件语法的信息。

笔者在执笔之初,考虑到降低初学过程中的各种心智负担,以及当时处于 script-setup 组件语法尚未完全稳定的背景下,所以在最后一章 高效开发 之前的章节,都只提供了组件的标准写法的讲解。

除非遇到框架进行了 Breaking Change (例如从 Vue 2 升级到了 Vue 3 这种大版本更新),否则标准写法是始终有效的,在笔者多年的学习经历和实际收获里,也是比较相信一点:不论学习什么语言或者框架,只要基础打的好,未来总是可以轻松地举一反三去解决遇到的各类问题。

因此如果读者通过各类脚手架创建的 Vue 3 项目,在组件看到 <script setup> 这种风格的组件,以及诸如 defineXxx 风格的 API ,这是 .vue 组件所支持的一种语法糖,可以随时在 高效开发 一章了解相关的 API 用法。

另外从 Vue 3.4 版本之后所新增的 API 未在本书的介绍范围内,请读者在阅读的过程中也可以查阅官方的 API 一起学习,不论是本书还是其他计算机书籍,官方文档永远是最好的查询手册。

TIP

语法糖(英语:Syntactic sugar)是由英国计算机科学家彼得·兰丁发明的一个术语,指计算机语言中添加的某种语法,这种语法对语言的功能没有影响,但是更方便程序员使用。语法糖让程序更加简洁,有更高的可读性。

插件的安装和引入

前端工程化 十分普及的今天,可以说几乎所有要用到的插件,都可以在 npmjs 上搜到,除了官方提供的包管理器 npm ,也有很多种安装方式选择。

TIP

如果还不了解什么是包和包管理器,请先阅读 了解包和插件 一节的内容。

另外,每个包管理都可以配置镜像源,提升国内的下载速度,对此也可以先阅读 配置镜像源 一节了解。

虽然对于个人开发者来说,有一个用的顺手的包管理器就足够日常开发了,但是还是有必要多了解一下不同的包管理器,因为未来可能会面对团队协作开发、为开源项目贡献代码等情况,需要遵循团队要求的包管理机制(例如使用 Monorepo 架构的团队会更青睐于 yarn 或 pnpm 的 Workspace 功能)。

通过 npm 安装

npm 是 Node.js 自带的包管理器,平时通过 npm install 命令来安装各种 npm 包(比如 npm install vue-router ),就是通过这个包管理器来安装的。

如果包的下载速度太慢,可以通过以下命令管理镜像源:

bash
# 查看下载源
npm config get registry

# 绑定下载源
npm config set registry https://registry.npmmirror.com

# 删除下载源
npm config rm registry

TIP

npm 的 lock 文件是 package-lock.json ,如果有管理多人协作仓库的需求,可以根据实际情况把它添加至 .gitignore 文件,便于统一团队的包管理。

通过 cnpm 安装

cnpm 是阿里巴巴推出的包管理工具,安装之后默认会使用 https://registry.npmmirror.com 这个镜像源。

它的安装命令和 npm 非常一致,通过 cnpm install 命令来安装(比如 cnpm install vue-router)。

在使用它之前,需要通过 npm 命令进行全局安装:

bash
npm install -g cnpm

# 或者
# npm install -g cnpm --registry=https://registry.npmmirror.com

TIP

cnpm 不生成 lock 文件,也不会识别项目下的 lock 文件,所以还是推荐使用 npm 或者其他包管理工具,通过绑定镜像源的方式来管理项目的包。

通过 yarn 安装

yarn 也是一个常用的包管理工具,和 npm 十分相似, npmjs 上的包,也会同步到 yarnpkg

也是需要全局安装才可以使用:

bash
npm install -g yarn

但是安装命令上会有点不同, yarn 是用 add 代替 install ,用 remove 代替 uninstall ,例如:

bash
# 安装单个包
yarn add vue-router

# 安装全局包
yarn global add typescript

# 卸载包
yarn remove vue-router

而且在运行脚本的时候,可以直接用 yarn 来代替 npm run ,例如 yarn dev 相当于 npm run dev

yarn 默认绑定的是 https://registry.yarnpkg.com 的下载源,如果包的下载速度太慢,也可以配置镜像源,但是命令有所差异:

bash
# 查看镜像源
yarn config get registry

# 绑定镜像源
yarn config set registry https://registry.npmmirror.com

# 删除镜像源(注意这里是 delete )
yarn config delete registry

TIP

yarn 的 lock 文件是 yarn.lock ,如果有管理多人协作仓库的需求,可以根据实际情况把它添加至 .gitignore 文件,便于统一团队的包管理。

通过 pnpm 安装

pnpm 是包管理工具的一个后起之秀,主打快速的、节省磁盘空间的特色,用法跟其他包管理器很相似,没有太多的学习成本, npm 和 yarn 的命令它都支持。

也是必须先全局安装它才可以使用:

bash
npm install -g pnpm

目前 pnpm 在开源社区的使用率越来越高,包括接触最多的 Vue / Vite 团队也在逐步迁移到 pnpm 来管理依赖。

pnpm 的下载源使用的是 npm ,所以如果要绑定镜像源,按照 npm 的方式 处理就可以了。

相关阅读:

TIP

pnpm 的 lock 文件是 pnpm-lock.yaml ,如果有管理多人协作仓库的需求,可以根据实际情况把它添加至 .gitignore 文件,便于统一团队的包管理。

通过 CDN 安装

大部分插件都会提供一个 CDN 版本,让可以在 .html 文件里通过 <script> 标签引入。

比如:

html
<script src="https://unpkg.com/vue-router"></script>

插件的引入

除了 CDN 版本是直接可用之外,其他通过 npm 、 yarn 等方式安装的插件,都需要在入口文件 main.ts 或者要用到的 .vue 文件里引入,比如:

ts
import { createRouter, createWebHistory } from 'vue-router'

因为本教程都是基于工程化开发,使用的 CLI 脚手架,所以这些内容暂时不谈及 CDN 的使用方式。

通常来说会有细微差别,但影响不大,插件作者也会在插件仓库的 README 或者使用文档里进行告知。

Vue 专属插件

这里特指 Vue 插件,通过 Vue Plugins 设计规范 开发出来的插件,在 npm 上通常是以 vue-xxx 这样带有 vue 关键字的格式命名(比如 vue-baidu-analytics)。

专属插件通常分为 全局插件单组件插件,区别在于,全局版本是在 main.ts 引入后 use,而单组件版本则通常是作为一个组件在 .vue 文件里引入使用。

全局插件的使用 ~new

在本教程最最前面的时候,特地说了一个内容就是 项目初始化 ,在这里有提到过就是需要通过 use 来初始化框架、插件。

全局插件的使用,就是在 main.ts 通过 import 引入,然后通过 use 来启动初始化。

在 Vue 2 ,全局插件是通过 Vue.use(xxx) 来启动,而现在,则需要通过 createAppuse,既可以单独一行一个 use ,也可以直接链式 use 下去。

参数

use 方法支持两个参数:

参数类型作用
pluginobject | function插件,一般是在 import 时使用的名称
optionsobject插件的参数,有些插件在初始化时可以配置一定的选项

基本的写法就是像下面这样:

ts
// main.ts
import plugin1 from 'plugin1'
import plugin2 from 'plugin2'
import plugin3 from 'plugin3'
import plugin4 from 'plugin4'

createApp(App)
  .use(plugin1)
  .use(plugin2)
  .use(plugin3, {
    // plugin3's options
  })
  .use(plugin4)
  .mount('#app')

大部分插件到这里就可以直接启动了,个别插件可能需要通过插件 API 去手动触发,在 npm 包的详情页或者 GitHub 仓库文档上,作者一般会告知使用方法,按照说明书操作即可。

单组件插件的使用 ~new

单组件的插件,通常自己本身也是一个 Vue 组件(大部分情况下都会打包为 JS 文件,但本质上是一个 Vue 的 Component )。

单组件的引入,一般都是在需要用到的 .vue 文件里单独 import ,然后挂到 <template /> 里去渲染,下面是一段模拟代码,理解起来会比较直观:

vue
<template>
  <!-- 放置组件的渲染标签,用于显示组件 -->
  <ComponentExample />
</template>

<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'
import logo from '@/assets/logo.png'

// 引入单组件插件
import ComponentExample from 'a-component-example'

export default defineComponent({
  // 挂载组件模板
  components: {
    ComponentExample,
  },
})
</script>

参考上面的代码还有注释,应该能大概了解如何使用单组件插件了吧!

通用 JS / TS 插件

也叫普通插件,这个 “普通” 不是指功能平平无奇,而是指它们无需任何框架依赖,可以应用在任意项目中,属于独立的 Library ,比如 axiosqrcodemd5 等等,在任何技术栈都可以单独引入使用,非 Vue 专属。

通用插件的使用非常灵活,既可以全局挂载,也可以在需要用到的组件里单独引入。

组件里单独引入方式:

ts
import { defineComponent } from 'vue'
import md5 from '@withtypes/md5'

export default defineComponent({
  setup() {
    const md5Msg = md5('message')
  },
})

全局挂载方法比较特殊,因为插件本身不是专属 Vue,没有 install 接口,无法通过 use 方法直接启动,下面有一小节内容单独讲这一块的操作,详见 全局 API 挂载

本地插件 ~new

插件也不全是来自于网上,有时候针对自己的业务,涉及到一些经常用到的功能模块,也可以抽离出来封装成项目专用的插件。

封装的目的

举个例子,比如在做一个具备用户系统的网站时,会涉及到手机短信验证码模块,在开始写代码之前,需要先要考虑到这些问题:

  1. 很多操作都涉及到下发验证码的请求,比如 “登录” 、 “注册” 、 “修改手机绑定” 、 “支付验证” 等等,代码雷同,只是接口 URL 或者参数不太一样

  2. 都是需要对手机号是否有传入、手机号的格式正确性验证等一些判断

  3. 需要对接口请求成功和失败的情况做一些不同的数据返回,但要处理的数据很相似,都是用于告知调用方当前是什么情况

  4. 返回一些 Toast 告知用户当前的交互结果

TIP

如果不把这一块的业务代码抽离出来,需要在每个用到的地方都写一次,不仅繁琐,而且以后一旦产品需求有改动,维护起来就惨了。

常用的封装类型

常用的本地封装方式有两种:一种是以 通用 JS / TS 插件 的形式,一种是以 Vue 专属插件 的形式。

关于这两者的区别已经在对应的小节有所介绍,接下来来看看如何封装它们。

开发本地通用 JS / TS 插件

一般情况下会以通用类型比较常见,因为大部分都是一些比较小的功能,而且可以很方便的在不同项目之间进行复用。

TIP

注:接下来会统一称之为 “通用插件” ,不论是基于 JavaScript 编写的还是 TypeScript 编写的。

项目结构

通常会把这一类文件都归类在 src 目录下的 libs 文件夹里,代表存放的是 Library 文件( JS 项目以 .js 文件存放, TS 项目以 .ts 文件存放)。

bash
vue-demo
 # 源码文件夹
├─src
  # 本地通用插件
 └─libs
   ├─foo.ts
   └─bar.ts

 # 其他结构这里省略…

└─package.json

这样在调用的时候,可以通过 @/libs/foo 来引入,或者配置了 alias 别名,也可以使用别名导入,例如 @libs/foo

设计规范与开发案例

在设计本地通用插件的时候,需要遵循 ES Module 模块设计规范 ,并且做好必要的代码注释(用途、入参、返回值等)。

TIP

如果还没有了解过 “模块” 的概念的话,可以先阅读 了解模块化设计 一节的内容。

一般来说,会有以下三种情况需要考虑。

只有一个默认功能

如果只有一个默认的功能,那么可以使用 export default 来默认导出一个函数。

例如需要封装一个打招呼的功能:

ts
// src/libs/greet.ts

/**
 * 向对方打招呼
 * @param name - 打招呼的目标人名
 * @returns 向传进来的人名返回一句欢迎语
 */
export default function greet(name: string): string {
  return `Welcome, ${name}!`
}

在 Vue 组件里就可以这样使用:

vue
<script lang="ts">
import { defineComponent } from 'vue'
// 导入本地插件
import greet from '@libs/greet'

export default defineComponent({
  setup() {
    // 导入的名称就是这个工具的方法名,可以直接调用
    const message = greet('Petter')
    console.log(message) // Welcome, Petter!
  },
})
</script>
是一个小工具合集

如果有很多个作用相似的函数,那么建议放在一个文件里作为一个工具合集统一管理,使用 export 来导出里面的每个函数。

例如需要封装几个通过 正则表达式 判断表单的输入内容是否符合要求的函数:

ts
// src/libs/regexp.ts

/**
 * 手机号校验
 * @param phoneNumber - 手机号
 * @returns true=是手机号,false=不是手机号
 */
export function isMob(phoneNumber: number | string): boolean {
  return /^1[3456789]\d{9}$/.test(String(phoneNumber))
}

/**
 * 邮箱校验
 * @param email - 邮箱地址
 * @returns true=是邮箱地址,false=不是邮箱地址
 */
export function isEmail(email: string): boolean {
  return /^[A-Za-z\d]+([-_.][A-Za-z\d]+)*@([A-Za-z\d]+[-.])+[A-Za-z\d]{2,4}$/.test(
    email
  )
}

在 Vue 组件里就可以这样使用:

vue
<script lang="ts">
import { defineComponent } from 'vue'
// 需要用花括号 {} 来按照命名导出时的名称导入
import { isMob, isEmail } from '@libs/regexp'

export default defineComponent({
  setup() {
    // 判断是否是手机号
    console.log(isMob('13800138000')) // true
    console.log(isMob('123456')) // false

    // 判断是否是邮箱地址
    console.log(isEmail('example@example.com')) // true
    console.log(isEmail('example')) // false
  },
})
</script>

TIP

类似这种情况,就没有必要为 isMobisEmail 每个方法都单独保存一个文件了,只需要统一放在 regexp.ts 正则文件里维护。

包含工具及辅助函数

如果主要提供一个独立功能,但还需要提供一些额外的变量或者辅助函数用于特殊的业务场景,那么可以用 export default 导出主功能,用 export 导出其他变量或者辅助函数。

只有一个默认功能 这个打招呼例子的基础上修改一下,默认提供的是 “打招呼” 的功能,偶尔需要更热情的赞美一下,那么这个 “赞美” 行为就可以用这个方式来放到这个文件里一起维护。

ts
// src/libs/greet.ts

/**
 * 称赞对方
 * @param name - 要称赞的目标人名
 * @returns 向传进来的人名发出一句赞美的话
 */
export function praise(name: string): string {
  return `Oh! ${name}! It's so kind of you!`
}

/**
 * 向对方打招呼
 * @param name - 打招呼的目标人名
 * @returns 向传进来的人名发出一句欢迎语
 */
export default function greet(name: string): string {
  return `Welcome, ${name}!`
}

在 Vue 组件里就可以这样使用:

vue
<script lang="ts">
import { defineComponent } from 'vue'
// 两者可以同时导入使用
import greet, { praise } from '@libs/greet'

export default defineComponent({
  setup() {
    // 使用默认的打招呼
    const message = greet('Petter')
    console.log(message) // Welcome, Petter!

    // 使用命名导出的赞美
    const praiseMessage = praise('Petter')
    console.log(praiseMessage) // Oh! Petter! It's so kind of you!
  },
})
</script>

开发本地 Vue 专属插件

Vue 专属插件 部分已介绍过,这一类的插件只能给 Vue 使用,有时候自己的业务比较特殊,无法找到完全适用的 npm 包,那么就可以自己写一个!

项目结构

通常会把这一类文件都归类在 src 目录下的 plugins 文件夹里,代表存放的是 Plugin 文件( JS 项目以 .js 文件存放, TS 项目以 .ts 文件存放)。

bash
vue-demo
 # 源码文件夹
├─src
  # 本地 Vue 插件
 └─plugins
   ├─foo.ts
   └─bar.ts

 # 其他结构这里省略…

└─package.json

这样在调用的时候,可以通过 @/plugins/foo 来引入,或者配置了 alias 别名,也可以使用别名导入,例如 @plugins/foo

设计规范

在设计本地 Vue 插件的时候,需要遵循 Vue 官方撰写的 Vue Plugins 设计规范 ,并且做好必要的代码注释,除了标明插件 API 的 “用途、入参、返回值” 之外,最好在注释内补充一个 Example 或者 Tips 说明,功能丰富的插件最好直接写个 README 文档。

开发案例

全局插件开发并启用后,只需要在 main.ts 里导入并 use 一次,即可在所有的组件内使用插件的功能。

下面对全局插件进行一个开发示范,希望能给大家以后需要的时候提供思路参考。

TIP

单组件插件一般作为 npm 包发布,会借助 Webpack 、 Vite 或者 Rollup 单独构建,本地直接放到 components 文件夹下作为组件管理即可。

基本结构

插件支持导出两种格式的:一种是函数,一种是对象。

  1. 当导出为一个函数时, Vue 会直接调用这个函数,此时插件内部是这样子:
ts
export default function (app, options) {
  // 逻辑代码...
}
  1. 当导出为一个对象时,对象上面需要有一个 install 方法给 Vue , Vue 通过调用这个方法来启用插件,此时插件内部是这样子:
ts
export default {
  install: (app, options) => {
    // 逻辑代码...
  },
}

不论哪种方式,入口函数都会接受两个入参:

参数作用类型
appcreateApp 生成的实例App (从 'vue' 里导入该类型),见下方的案例演示
options插件初始化时的选项undefined 或一个对象,对象的 TS 类型由插件的选项决定

如果需要在插件初始化时传入一些必要的选项,可以定义一个对象作为 options ,这样只要在 main.tsuse 插件时传入第二个参数,插件就可以拿到它们:

ts
// src/main.ts
createApp(App)
  // 注意这里的第二个参数就是插件选项
  .use(customPlugin, {
    foo: 1,
    bar: 2,
  })
  .mount('#app')
编写插件

这里以一个 自定义指令 为例,写一个用于管理自定义指令的插件,其中包含两个自定义指令:一个是判断是否有权限,一个是给文本高亮,文本高亮还支持一个插件选项。

ts
// src/plugins/directive.ts
import type { App } from 'vue'

// 插件选项的类型
interface Options {
  // 文本高亮选项
  highlight?: {
    // 默认背景色
    backgroundColor: string
  }
}

/**
 * 自定义指令
 * @description 保证插件单一职责,当前插件只用于添加自定义指令
 */
export default {
  install: (app: App, options?: Options) => {
    /**
     * 权限控制
     * @description 用于在功能按钮上绑定权限,没权限时会销毁或隐藏对应 DOM 节点
     * @tips 指令传入的值是管理员的组别 id
     * @example <div v-permission="1" />
     */
    app.directive('permission', (el, binding) => {
      // 假设 1 是管理员组别的 id ,则无需处理
      if (binding.value === 1) return

      // 其他情况认为没有权限,需要隐藏掉界面上的 DOM 元素
      if (el.parentNode) {
        el.parentNode.removeChild(el)
      } else {
        el.style.display = 'none'
      }
    })

    /**
     * 文本高亮
     * @description 用于给指定的 DOM 节点添加背景色,搭配文本内容形成高亮效果
     * @tips 指令传入的值需要是合法的 CSS 颜色名称或者 Hex 值
     * @example <div v-highlight="`cyan`" />
     */
    app.directive('highlight', (el, binding) => {
      // 获取默认颜色
      let defaultColor = 'unset'
      if (
        Object.prototype.toString.call(options) === '[object Object]' &&
        options?.highlight?.backgroundColor
      ) {
        defaultColor = options.highlight.backgroundColor
      }

      // 设置背景色
      el.style.backgroundColor =
        typeof binding.value === 'string' ? binding.value : defaultColor
    })
  },
}
启用插件

main.ts 全局启用插件,在启用的时候传入了第二个参数 “插件的选项” ,这里配置了个高亮指令的默认背景颜色:

ts
// src/main.ts
import { createApp } from 'vue'
import App from '@/App.vue'
import directive from '@/plugins/directive' // 导入插件

createApp(App)
   // 自定义插件
  .use(directive, {
    highlight: {
      backgroundColor: '#ddd',
    },
  })
  .mount('#app')
使用插件

在 Vue 组件里使用:

vue
<template>
  <!-- 测试 permission 指令 -->
  <div>根据 permission 指令的判断规则:</div>
  <div v-permission="1">这个可以显示</div>
  <div v-permission="2">这个没有权限,会被隐藏</div>

  <!-- 测试 highlight 指令 -->
  <div>根据 highlight 指令的判断规则:</div>
  <div v-highlight="`cyan`">这个是青色高亮</div>
  <div v-highlight="`yellow`">这个是黄色高亮</div>
  <div v-highlight="`red`">这个是红色高亮</div>
  <div v-highlight>这个是使用插件初始化时设置的灰色</div>
</template>

全局 API 挂载

对于一些使用频率比较高的插件方法,如果觉得在每个组件里单独导入再用很麻烦,也可以考虑将其挂载到 Vue 上,使其成为 Vue 的全局变量。

注:接下来的全局变量,都是指 Vue 环境里的全局变量,非 Window 下的全局变量。

回顾 Vue 2

在 Vue 2 ,可以通过 prototype 的方式来挂载全局变量,然后通过 this 关键字来从 Vue 原型上调用该方法。

md5 插件为例,在 main.ts 里进行全局 import,然后通过 prototype 去挂到 Vue 上。

ts
import Vue from 'vue'
import md5 from 'md5'

Vue.prototype.$md5 = md5

之后在 .vue 文件里,就可以这样去使用 md5

ts
const md5Msg = this.$md5('message')

了解 Vue 3 ~new

在 Vue 3 ,已经不再支持 prototype 这样使用了,在 main.ts 里没有了 Vue,在组件的生命周期里也没有了 this

如果依然想要挂载全局变量,需要通过全新的 globalProperties 来实现,在使用该方式之前,可以把 createApp 定义为一个变量再执行挂载。

定义全局 API ~new

如上,在配置全局变量之前,可以把初始化时的 createApp 定义为一个变量(假设为 app ),然后把需要设置为全局可用的变量或方法,挂载到 appconfig.globalProperties 上面。

ts
import md5 from 'md5'

// 创建 Vue 实例
const app = createApp(App)

// 把插件的 API 挂载全局变量到实例上
app.config.globalProperties.$md5 = md5

// 也可以自己写一些全局函数去挂载
app.config.globalProperties.$log = (text: string): void => {
  console.log(text)
}

app.mount('#app')

全局 API 的替代方案

在 Vue 3 实际上并不是特别推荐使用全局变量,Vue 3 比较推荐按需引入使用,这也是在构建过程中可以更好的做到代码优化。

特别是针对 TypeScript , Vue 作者尤雨溪先生对于全局 API 的相关 PR 说明: Global API updates ,也是不建议在 TS 里使用。

那么确实是需要用到一些全局 API 怎么办?

对于一般的数据和方法,建议采用 Provide / Inject 方案,在根组件(通常是 App.vue )把需要作为全局使用的数据或方法 Provide 下去,在需要用到的组件里通过 Inject 即可获取到,或者使用 EventBus / Vuex / Pinia 等全局通信方案来处理。

npm 包的开发与发布

相信很多开发者都想发布一个属于自己的 npm 包,在实际的工作中,也会有一些公司出于开发上的便利,也会将一些常用的业务功能抽离为独立的 npm 包,提前掌握包的开发也是非常重要的能力,接下来将介绍如何从 0 到 1 开发一个 npm 包,并将其发布到 npmjs 上可供其他项目安装使用。

TIP

在开始本节内容之前,请先阅读或回顾以下两部分内容:

  1. 阅读 了解 package.json 一节,了解或重温 npm 包清单文件的作用
  2. 阅读 学习模块化设计 一节,了解或重温模块化开发的知识

常用的构建工具

平时项目里用到的 npm 包,也可以理解为是一种项目插件,一些很简单的包,其实就和编写 本地插件 一样,假设包的入口文件是 index.js ,那么可以直接在 index.js 里编写代码,再进行模块化导出。

其他项目里安装这个包之后就可以直接使用里面的方法了,这种方式适合非常非常简单的包,很多独立的工具函数包就是使用这种方式来编写包的源代码。

例如 is-number 这个包,每周下载量超过 6800 万次,它的源代码非常少:

js
/**
 * 摘自 is-number 的入口文件
 * @see https://github.com/jonschlinkert/is-number/blob/master/index.js
 */
module.exports = function (num) {
  if (typeof num === 'number') {
    return num - num === 0
  }
  if (typeof num === 'string' && num.trim() !== '') {
    return Number.isFinite ? Number.isFinite(+num) : isFinite(+num)
  }
  return false
}

再如 slash 这个包,每周下载量超过 5200 万次,它的源代码也是只有几行:

js
/**
 * 摘自 slash 的入口文件
 * @see https://github.com/sindresorhus/slash/blob/main/index.js
 */
export default function slash(path) {
  const isExtendedLengthPath = /^\\\\\?\\/.test(path)

  if (isExtendedLengthPath) {
    return path
  }

  return path.replace(/\\/g, '/')
}

但这一类包通常是提供很基础的功能实现,更多时候需要自己开发的包更倾向于和框架、和业务挂钩,涉及到非 JavaScript 代码,例如 Vue 组件的编译、 Less 等 CSS 预处理器编译、 TypeScript 的编译等等,如果不通过构建工具来处理,那么发布后这个包的使用就会有诸多限制,需要满足和开发这个包时一样的开发环境才能使用,这对于使用者来说非常不友好。

因此大部分 npm 包的开发也需要用到构建工具来转换项目源代码,统一输出为一个兼容性更好、适用性更广的 JavaScript 文件,配合 .d.ts 文件的类型声明,使用者可以不需要特地配置就可以开箱即用,非常方便,非常友好。

传统的 Webpack 可以用来构建 npm 包文件,但按照目前更主流的技术选项,编译结果更干净更迷的当属 Rollup ,但 Rollup 需要配置很多插件功能,这对于刚接触包开发的开发者来说学习成本比较高,而 Vite 的出现则解决了这个难题,因为 Vite 的底层是基于 Rollup 来完成构建,上层则简化了很多配置上的问题,因此接下来将使用 Vite 来带领开发者入门 npm 包的开发。

TIP

在开始使用构建工具之前,请先在命令行使用 node -v 命令检查当前的 Node.js 版本号是否在构建工具的支持范围内,避免无法正常使用构建工具。

通常可以在构建工具的官网查询到其支持的 Node 版本,以 Vite 为例,可以在 Vite 官网的 Node 支持 一节了解到当前只能在 Node 14.18+ / 16+ 版本上使用 Vite 。

当构建工具所支持的 Node 版本和常用的 Node 版本出现严重冲突时,推荐使用 nvm / nvm-windows 或者 n 等 Node 版本管理工具安装多个不同版本的 Node ,即可根据开发需求很方便的切换不同版本的 Node 进行开发。

项目结构与入口文件

在动手开发具体功能之前,先把项目框架搭起来,熟悉常用的项目结构,以及如何配置项目清单信息。

TIP

当前文档所演示的 hello-lib 项目已托管至 learning-vue3/hello-lib 仓库,可使用 Git 克隆命令拉取至本地:

bash
# 从 GitHub 克隆
git clone https://github.com/learning-vue3/hello-lib.git

# 如果 GitHub 访问失败,可以从 Gitee 克隆
git clone https://gitee.com/learning-vue3/hello-lib.git

成品项目可作为学习过程中的代码参考,但更建议按照教程的讲解步骤,从零开始亲手搭建一个新项目并完成 npm 包的开发流程,可以更有效的提升学习效果。

初始化项目

首先需要初始化一个 Node 项目,打开命令行工具,先使用 cd 命令进入平时存放项目的目录,再通过 mkdir 命令创建一个项目文件夹,这里起名为 hello-lib

bash
# 创建一个项目文件夹
mkdir hello-lib

创建了项目文件夹之后,使用 cd 命令进入项目,执行 Node 的项目初始化命令:

bash
# 进入项目文件夹
cd hello-lib

# 执行初始化,使其成为一个 Node 项目
npm init -y

此时 hello-lib 目录下会生成一个 package.json 文件,由于后面还需要手动调整该文件的信息,所以初始化的时候可以添加 -y 参数使用默认的初始化数据直接生成该文件,跳过答题环节。

配置包信息

对一个 npm 包来说,最重要的文件莫过于 package.json 项目清单,其中有三个字段是必填的:

字段是否必填作用
name必填npm 包的名称,遵循 项目名称的规则
version必填npm 包的版本号,遵循 语义化版本号的规则
main必填项目的入口文件,通常指向构建产物所在目录的某个文件,该文件通常包含了所有模块的导出。

如果只指定了 main 字段,则使用 requireimport 以及浏览器访问 npm 包的 CDN 时,都将默认调用该字段指定的入口文件。

如果有指定 modulebrowser 字段,则通常对应 cjs 格式的文件,对应 CommonJS 规范。
module当项目使用 import 引入 npm 包时对应的入口文件,通常指向一个 es 格式的文件,对应 ES Module 规范。
browser当项目使用了 npm 包的 CDN 链接,在浏览器访问页面时的入口文件,通常指向一个 umd 格式的文件,对应 UMD 规范。
types一个 .d.ts 类型声明文件,包含了入口文件导出的方法 / 变量的类型声明,如果项目有自带类型文件,那么在使用者在使用 TypeScript 开发的项目里,可以得到友好的类型提示
files指定发布到 npm 上的文件范围,格式为 string[] 支持配置多个文件名或者文件夹名称。

通常可以只指定构建的输出目录,例如 dist 文件夹,如果不指定,则发布的时候会把所有源代码一同发布。

其中 mainmodulebrowser 三个入口文件对应的文件格式和规范,通常都是交给构建工具处理,无需手动编写,开发者只需要维护一份源码即可编译出不同规范的 JS 文件, types 对应的类型声明文件也是由工具来输出,无需手动维护。

而其他的字段可以根据项目的性质决定是否补充,以下是 hello-lib 的基础信息示例:

json
{
  "name": "@learning-vue3/lib",
  "version": "1.0.0",
  "description": "A library demo for learning-vue3.",
  "author": "chengpeiquan <chengpeiquan@chengpeiquan.com>",
  "homepage": "https://github.com/learning-vue3/hello-lib",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/learning-vue3/hello-lib.git"
  },
  "license": "MIT",
  "files": ["dist"],
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "browser": "dist/index.min.js",
  "types": "dist/index.d.ts",
  "keywords": ["library", "demo", "example"],
  "scripts": {
    "build": "vite build"
  }
}

此时 mainmodulebrowsertypes 字段对应的文件还不存在,它们将在项目执行 npm run build 构建之后才会产生。

另外,入口文件使用了不同规范对应的文件扩展名,也可以统一使用 .js 扩展名,通过文件名来区分,例如 es 格式使用 index.es.js

scripts 字段则配置了一个 build 命令,这里使用了 Vite 的构建命令来打包项目,这个过程会读取 Vite 的配置文件 vite.config.ts ,关于该文件的配置内容将在下文继续介绍。

安装开发依赖

本次的 npm 包将使用 Vite 进行构建,使用 TypeScript 编写源代码,由于 Vite 本身对 TypeScript 进行了支持,因此只需要将 Vite 安装到开发依赖:

bash
# 添加 -D 选项将其安装到 devDependencies
npm i -D vite

添加配置文件

配置包信息 的时候已提前配置了一个 npm run build 的命令,它将运行 Vite 来构建 npm 包的入口文件。

由于 Vite 默认是构建入口文件为 HTML 的网页应用,而开发 npm 包时入口文件是 JS / TS 文件,因此需要添加一份配置文件来指定构建的选项。

以下是本次的基础配置,可以完成最基本的打包,它将输出三个不同格式的入口文件,分别对应 CommonJS 、 ES Module 和 UMD 规范,分别对应 package.json 里 mainmodulebrowser 字段指定的文件。

ts
// vite.config.ts
import { defineConfig } from 'vite'

// https://cn.vitejs.dev/config/
export default defineConfig({
  build: {
    // 输出目录
    outDir: 'dist',
    // 构建 npm 包时需要开启 “库模式”
    lib: {
      // 指定入口文件
      entry: 'src/index.ts',
      // 输出 UMD 格式时,需要指定一个全局变量的名称
      name: 'hello',
      // 最终输出的格式,这里指定了三种
      formats: ['es', 'cjs', 'umd'],
      // 针对不同输出格式对应的文件名
      fileName: (format) => {
        switch (format) {
          // ES Module 格式的文件名
          case 'es':
            return 'index.mjs'
          // CommonJS 格式的文件名
          case 'cjs':
            return 'index.cjs'
          // UMD 格式的文件名
          default:
            return 'index.min.js'
        }
      },
    },
    // 压缩混淆构建后的文件代码
    minify: true,
  },
})

添加入口文件

来到这里,最基础的准备工作已完成,接下来添加入口文件并尝试编译。

添加配置文件 时已指定了入口文件为 src/index.ts ,因此需要对应的创建该文件,并写入一个简单的方法,将用它来测试打包结果:

ts
// src/index.ts
export default function hello(name: string) {
  console.log(`Hello ${name}`)
}

在命令行执行 npm run build 命令,可以看到项目下生成了 dist 文件夹,以及三个 JavaScript 文件,此时目录结构如下:

bash
hello-lib
 # 构建产物的输出文件夹
├─dist
 ├─index.cjs
 ├─index.min.js
 └─index.mjs
 # 依赖文件夹
├─node_modules
 # 源码文件夹
├─src
  # 入口文件
 └─index.ts
 # 项目清单信息
├─package-lock.json
├─package.json
 # Vite 配置文件
└─vite.config.ts

打开 dist 目录下的文件内容,可以看到虽然源码是使用 TypeScript 编写的,但最终输出的内容是按照指定的格式转换为 JavaScript 并且被执行了压缩和混淆,在这里将它们重新格式化,来看看转换后的结果。

这是 index.cjs 的文件内容,源码被转换为 CommonJS 风格的代码:

js
// dist/index.cjs
'use strict'
function l(o) {
  console.log(`Hello ${o}`)
}
module.exports = l

这是 index.mjs 的内容,源码被转换为 ES Module 风格的代码:

js
// dist/index.mjs
function o(l) {
  console.log(`Hello ${l}`)
}
export { o as default }

这是 index.min.js 的内容,源码被转换为 UMD 风格的代码:

js
// dist/index.min.js
;(function (e, n) {
  typeof exports == 'object' && typeof module < 'u'
    ? (module.exports = n())
    : typeof define == 'function' && define.amd
    ? define(n)
    : ((e = typeof globalThis < 'u' ? globalThis : e || self), (e.hello = n()))
})(this, function () {
  'use strict'
  function e(n) {
    console.log(`Hello ${n}`)
  }
  return e
})

来到这里,准备工作已就绪,下一步将开始进入工具包和组件包的开发。

开发 npm 包

这里先从最简单的函数库开始入门包的开发,为什么说它简单呢?因为只需要编写 JavaScript 或 TypeScript 就可以很好的完成开发工作。

在理解了包的开发流程之后,如果要涉及 Vue 组件包的开发,则安装相关的 Vue 的相关依赖、 Less 等 CSS 预处理器依赖,只要满足了编译条件,就可以正常构建和发布,它们的开发流程是一样的。

编写 npm 包代码

在开发的过程中,需要遵循模块化开发的要求,当前这个演示包使用 TypeScript 编码,就需要 使用 ES Module 来设计模块 ,如果对模块化设计还没有足够的了解,请先回顾相关的内容。

先在 src 目录下创建一个名为 utils.ts 的文件,写入以下内容:

ts
// src/utils.ts

/**
 * 生成随机数
 * @param min - 最小值
 * @param max - 最大值
 * @param roundingType - 四舍五入类型
 * @returns 范围内的随机数
 */
export function getRandomNumber(
  min: number = 0,
  max: number = 100,
  roundingType: 'round' | 'ceil' | 'floor' = 'round'
) {
  return Math[roundingType](Math.random() * (max - min) + min)
}

/**
 * 生成随机布尔值
 */
export function getRandomBoolean() {
  const index = getRandomNumber(0, 1)
  return [true, false][index]
}

这里导出了两个随机方法,其中 getRandomNumber 提供了随机数值的返回,而 getRandomBoolean 提供了随机布尔值的返回,在源代码方面, getRandomBoolean 调用了 getRandomNumber 获取随机索引。

这是一个很常见的 npm 工具包的开发思路,包里的函数都使用了细粒度的编程设计,每一个函数都是独立的功能,在必要的情况下,函数 B 可以调用函数 A 来减少代码的重复编写。

在这里, utils.ts 文件已开发完毕,接下来需要将它导出的方法提供给包的使用者,请删除入口文件 src/index.ts 原来的测试内容,并输入以下新代码:

ts
// src/index.ts
export * from './utils'

这代表将 utils.ts 文件里导出的所有方法或者变量,再次导出去,如果有很多个 utils.ts 这样的文件, index.ts 将作为一个统一的入口,统一的导出给构建工具去编译输出。

接下来在命令行执行 npm run build ,再分别看看 dist 目录下的文件变化:

此时的 index.cjs 文件,已经按照 CommonJS 规范转换了源代码:

js
// dist/index.cjs
'use strict'
Object.defineProperties(exports, {
  __esModule: { value: !0 },
  [Symbol.toStringTag]: { value: 'Module' },
})
function t(e = 0, o = 100, n = 'round') {
  return Math[n](Math.random() * (o - e) + e)
}
function r() {
  const e = t(0, 1)
  return [!0, !1][e]
}
exports.getRandomBoolean = r
exports.getRandomNumber = t

index.mjs 也按照 ES Module 规范进行了转换:

js
// dist/index.mjs
function o(n = 0, t = 100, e = 'round') {
  return Math[e](Math.random() * (t - n) + n)
}
function r() {
  const n = o(0, 1)
  return [!0, !1][n]
}
export { r as getRandomBoolean, o as getRandomNumber }

index.min.js 同样正常按照 UMD 风格转换成了 JavaScript 代码:

js
// dist/index.min.js
;(function (e, n) {
  typeof exports == 'object' && typeof module < 'u'
    ? n(exports)
    : typeof define == 'function' && define.amd
    ? define(['exports'], n)
    : ((e = typeof globalThis < 'u' ? globalThis : e || self),
      n((e.hello = {})))
})(this, function (e) {
  'use strict'
  function n(o = 0, u = 100, d = 'round') {
    return Math[d](Math.random() * (u - o) + o)
  }
  function t() {
    const o = n(0, 1)
    return [!0, !1][o]
  }
  ;(e.getRandomBoolean = t),
    (e.getRandomNumber = n),
    Object.defineProperties(e, {
      __esModule: { value: !0 },
      [Symbol.toStringTag]: { value: 'Module' },
    })
})

对 npm 包进行本地调试

开发或者迭代了一个 npm 包之后,不建议直接发布,可以在本地进行测试,直到没有问题了再发布到 npmjs 上供其他人使用。

npm 提供了一个 npm link 命令供开发者本地联调,假设 path/to/my-library 是一个 npm 包的项目路径, path/to/my-project 是一个调试项目的所在路径,那么通过以下步骤可以在 my-project 里本地调试 my-library 包。

TIP

关于 path/to 的典故,可以查看 #294 了解。

创建本地软链接

先在 my-library npm 包项目里执行 npm link 命令,创建 npm 包的本地软链接:

bash
# 进入 npm 包项目所在的目录
cd path/to/my-library

# 创建 npm 包的本地软链接
npm link

运行了以上命令之后,意味着刚刚开发好的 npm 包,已经被成功添加到了 Node 的全局安装目录下,可以在命令行运行以下命令查看全局安装目录的位置:

bash
npm prefix -g

假设 {prefix} 是全局安装目录,刚刚这个包在 package.json 里的包名称是 my-library ,那么在 {prefix}/node_modules/my-library 这个目录下可以看到被软链接了一份项目代码。

TIP

软链接( Symbolic Link / Symlink / Soft Link ),是指通过指定路径来指向文件或目录,操作系统会自动将其解释为另一个文件或目录的路径,因此软链接被删除或修改不会影响源文件,而源文件的移动或者删除,不会自动更新软链接,这一点和快捷方式的作用比较类似。

自此已经对这个 npm 包完成了一次 “本地发布” ,接下来就要在调试项目里进行本地关联。

关联本地软链接

my-project 调试项目里执行语法为 npm link [<package-spec>] 的 link 命令,关联 npm 包的本地软链接。

TIP

这里的 [<package-spec>] 参数,可以是包名称,也可以是 npm 包项目所在的路径。

bash
# 进入调试项目所在的目录
cd path/to/my-project

# 通过 npm 包的包名称关联本地软链接
npm link my-library

如果通过 npm 包名称关联失败,例如返回了如下信息:

bash
 npm link my-library
npm ERR! code E404
npm ERR! 404 Not Found - GET https://registry.npmjs.org/my-library - Not found
npm ERR! 404
npm ERR! 404  'my-library@*' is not in this registry.
npm ERR! 404
npm ERR! 404 Note that you can also install from a
npm ERR! 404 tarball, folder, http url, or git url.

这种情况通常出现于本地 npm 包还没有在 npmjs 上进行过任意版本的发布,而包管理器又找不到本地全局安装目录的软链接,就会去 npm 源找,都找不到就会返回 404 的报错,针对这种情况,也可以使用 npm 包项目的路径进行关联:

bash
# 进入调试项目所在的目录
cd path/to/my-project

# 通过 npm 包的项目路径关联本地软链接
npm link path/to/my-library

至此,就完成了调试项目对该 npm 包在本地的 “安装” ,此时在 my-project 这个调试项目的 node_modules 目录下也会创建一个软链接,指向 my-library 所在的目录。

回归当前的演示包项目,先创建一个基于 TypeScript 的 Vue 新项目作为调试项目,在关联了本地 npm 包之后,就可以在调试项目里编写如下代码,测试 npm 包里的方法是否可以正常使用:

ts
// 请将 `@learning-vue3/lib` 更换为实际的包名称
import { getRandomNumber } from '@learning-vue3/lib'

const num = getRandomNumber()
console.log(num)

启动 npm run dev 的调试命令并打开本地调试页面,就可以在浏览器控制台正确的打印出了随机结果。

因为本包还支持 UMD 规范,所以也可以在 HTML 页面通过普通的 <script /> 标签直接引入 dist 目录下的文件测试将来引入 CDN 时的效果,可以在 npm 包项目下创建一个 demo 目录,并添加一个 index.html 文件到该目录下,并写入以下内容:

html
<!-- demo/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Library Demo</title>
  </head>
  <body>
    <!-- 这里引入的是 UMD 规范的文件 -->
    <script src="../dist/index.min.js"></script>
    <script>
      /**
       * UMD 规范的文件会有一个全局变量
       * 由 vite.config.ts 的 `build.lib.name` 决定
       */
      console.log(hello)

      /**
       * 所有的方法会挂在这个全局变量上
       * 类似于 jQuery 的 $.xxx() 那样使用
       */
      const num = hello.getRandomNumber()
      console.log(num)
    </script>
  </body>
</html>

在浏览器打开该 HTML 文件并唤起控制台,一样可以看到随机结果的打印记录。

添加版权注释

很多知名项目在 Library 文件的开头都会有一段版权注释,它的作用除了声明版权归属之外,还会告知使用者关于项目的主页地址、版本号、发布日期、 BUG 反馈渠道等信息。

例如很多开发者入门前端时使用过的经典类库 jQuery :

js
// https://cdn.jsdelivr.net/npm/jquery@3.6.1/dist/jquery.js

/*!
 * jQuery JavaScript Library v3.6.1
 * https://jquery.com/
 *
 * Includes Sizzle.js
 * https://sizzlejs.com/
 *
 * Copyright OpenJS Foundation and other contributors
 * Released under the MIT license
 * https://jquery.org/license
 *
 * Date: 2022-08-26T17:52Z
 */
( function( global, factory ) {
// ...

又如流行的 JavaScript 工具库 Lodash :

js
// https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js

/**
 * @license
 * Lodash <https://lodash.com/>
 * Copyright OpenJS Foundation and other contributors <https://openjsf.org/>
 * Released under MIT license <https://lodash.com/license>
 * Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>
 * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
 */
(function(){
// ...

还有每次做轮播图一定会想到它的 Swiper :

js
// https://cdn.jsdelivr.net/npm/swiper@8.4.3/swiper-bundle.js

/**
 * Swiper 8.4.3
 * Most modern mobile touch slider and framework
 * with hardware accelerated transitions
 * https://swiperjs.com
 *
 * Copyright 2014-2022 Vladimir Kharlampidi
 *
 * Released under the MIT License
 *
 * Released on: October 6, 2022
 */
(function (global, factory) {
// ...

聪明的开发者肯定已经猜到了,这些版权注释肯定不是手动添加的,那么它们是如何自动生成的呢?

npm 社区提供了非常多开箱即用的注入插件,通常可以通过 “当前使用的构建工具名称” 加上 “plugin banner” 这样的关键字,在 npmjs 网站上搜索是否有相关的插件,以当前使用的 Vite 为例,可以通过 vite-plugin-banner 实现版权注释的自动注入。

回到 hello-lib 项目,安装该插件到 devDependencies :

bash
npm i -D vite-plugin-banner

根据插件的文档建议,打开 vite.config.ts 文件,将其导入,并通过读取 package.json 的信息来生成常用的版权注释信息:

ts
// vite.config.ts
import { defineConfig } from 'vite'
// 导入版权注释插件
import banner from 'vite-plugin-banner'
// 导入 npm 包信息
import pkg from './package.json'

// https://cn.vitejs.dev/config/
export default defineConfig({
  // 其他选项保持不变
  // ...
  plugins: [
    // 新增 banner 插件的启用,传入 package.json 的字段信息
    banner(
      `/**\n * name: ${pkg.name}\n * version: v${pkg.version}\n * description: ${pkg.description}\n * author: ${pkg.author}\n * homepage: ${pkg.homepage}\n */`
    ),
  ],
})

再次运行 npm run build 命令,打开 dist 目录下的 Library 文件,可以看到都成功添加了一段版权注释:

js
// dist/index.mjs

/**
 * name: @learning-vue3/lib
 * version: v1.0.0
 * description: A library demo for learning-vue3.
 * author: chengpeiquan <chengpeiquan@chengpeiquan.com>
 * homepage: https://github.com/learning-vue3/hello-lib
 */
function o(n = 0, t = 100, e = 'round') {
  return Math[e](Math.random() * (t - n) + n)
}
function r() {
  const n = o(0, 1)
  return [!0, !1][n]
}
export { r as getRandomBoolean, o as getRandomNumber }

这样其他开发者如果在使用过程中遇到了问题,就可以轻松找到插件作者的联系方式了!

TIP

请根据实际的 package.json 存在的字段信息调整 banner 内容。

生成 npm 包的类型声明

虽然到这里已经得到一个可以运行的 JavaScript Library 文件,在 JavaScript 项目里使用是完全没有问题的,但还不建议直接发布到 npmjs 上,因为目前的情况下在 TypeScript 项目并不能完全兼容,还需要生成一份 npm 包的类型声明文件。

为什么需要类型声明

如果在上一小节 关联本地软链接 创建 Vue 调试项目时,也是使用了 TypeScript 版本的 Vue 项目,会遇到 VSCode 在下面这句代码上:

ts
import { getRandomNumber } from '@learning-vue3/lib'

在包名称 '@learning-vue3/lib' 的位置提示了一个红色波浪线,把鼠标移上去会显示这么一段话:

无法找到模块 “@learning-vue3/lib” 的声明文件。 “D:/Project/demo/hello-lib/dist/index.cjs” 隐式拥有 "any" 类型。
尝试使用 npm i --save-dev @types/learning-vue3__lib (如果存在),或者添加一个包含 declare module '@learning-vue3/lib'; 的新声明 (.d.ts) 文件 ts(7016)

此时在命令行运行 Vue 调试项目的打包命令 npm run build ,也会遇到打包失败的报错,控制台同样反馈了这个问题:缺少声明文件。

bash
 npm run build

> hello-vue3@0.0.0 build
> vue-tsc --noEmit && vite build

src/App.vue:8:30 - error TS7016: Could not find a declaration file for module '@learning-vue3/lib'. 'D:/Project/demo/hello-lib/dist/index.cjs' implicitly has an 'any' type.
  Try `npm i --save-dev @types/learning-vue3__lib` if it exists or add a new declaration (.d.ts) file containing `declare module '@learning-vue3/lib';`

8 import { getRandomNumber } from '@learning-vue3/lib'
                               ~~~~~~~~~~~~~~~~~~~~


Found 1 error in src/App.vue:8

虽然使用者可以按照报错提示,在调试项目下创建一个 d.ts 文件并写入以下内容来声明该 npm 包:

ts
declare module '@learning-vue3/lib'

但这需要每个使用者,或者说每个使用到这个包的项目都声明一次,对于使用者来说非常不友好, declare module 之后虽然不会报错了,但也无法获得 VSCode 对 npm 包提供的 API 进行 TS 类型的自动推导与类型提示、代码补全等功能支持。

主流的做法

细心的开发者在 npmjs 网站上搜索 npm 包时,会发现很多 npm 包在详情页的包名后面,跟随有一个蓝色的 TS 图标,鼠标移上去时,还会显示一句提示语:

This package contains built-in TypeScript declarations

例如上图的 @vue/reactivity , Vue 3 的响应式 API 包,就带有这个图标。

这表示带有这个图标的 npm 包,已包含内置的 TypeScript 声明,可以获得完善的 TS 类型推导和提示支持,开发过程中也可以获得完善的代码补全功能支持,提高开发效率,在 TypeScript 项目执行 npm run build 的时候也能够被成功打包。

以 @vue/reactivity 这个包为例,如果项目下安装有这个 npm 包,可以在

bash
# 基于项目根目录
./node_modules/@vue/reactivity/dist/reactivity.d.ts

这个文件里查看 Vue 3 响应式 API 的类型声明,也可以通过该文件的 CDN 地址访问到其内容:

bash
https://cdn.jsdelivr.net/npm/@vue/reactivity@3.2.40/dist/reactivity.d.ts

生成 DTS 文件

有在 “快速上手 TypeScript ” 一章阅读过 了解 tsconfig.json 这节内容的开发者,应该对该文件有了一定的了解,如果还没有阅读过也没关系,可以先按照下方的步骤操作,接下来将分布说明如何生成 npm 包的 DTS 类型声明文件(以 .d.ts 为扩展名的文件)。

请先全局安装 typescript 这个包:

bash
npm install -g typescript

依然是在在命令行界面,回到 hello-lib 这个 npm 包项目的根目录,执行以下命令生成 tsconfig.json 文件:

bash
tsc --init

打开 tsconfig.json 文件,生成的文件里会有很多默认被注释掉的选项,请将以下几个选项取消注释,同时在 compilerOptions 字段的同级新增 include 字段,这几个选项都修改为如下配置:

json
{
  "compilerOptions": {
    "declaration": true,
    "emitDeclarationOnly": true,
    "declarationDir": "./dist"
  },
  "include": ["./src"]
}

其中 compilerOptions 三个选项的意思是: .ts 源文件不编译为 .js 文件,只生成 .d.ts 文件并输出到 dist 目录; include 选项则告诉 TypeScript 编译器,只处理 src 目录下的 TS 文件。

修改完毕后,在命令行执行以下命令,它将根据 tsconfig.json 的配置对项目进行编译:

bash
tsc

可以看到现在的 dist 目录下多了 2 份 .d.ts 文件: index.d.ts 和 utils.d.ts 。

bash
hello-lib
└─dist
  ├─index.cjs
  ├─index.d.ts
  ├─index.min.js
  ├─index.mjs
  └─utils.d.ts

打开 dist/index.d.ts ,可以看到它的内容和 src/index.ts 是一样的,因为作为入口文件,只提供了模块的导出:

ts
// dist/index.d.ts
export * from './utils'

再打开 dist/utils.d.ts ,可以看到它的内容如下,对比 src/utils.ts 的文件内容,它去掉了具体的功能实现,并且根据代码逻辑,转换成了 TypeScript 的类型声明:

ts
// dist/utils.d.ts
/**
 * 生成随机数
 * @param min - 最小值
 * @param max - 最大值
 * @param roundingType - 四舍五入类型
 * @returns 范围内的随机数
 */
export declare function getRandomNumber(
  min?: number,
  max?: number,
  roundingType?: 'round' | 'ceil' | 'floor'
): number
/**
 * 生成随机布尔值
 */
export declare function getRandomBoolean(): boolean

由于 hello-lib 项目的 package.json 已提前指定了类型声明文件指向:

json
{
  "types": "dist/index.d.ts"
}

因此可以直接回到调试 npm 包的 Vue 项目,此时 VSCode 对那句 import 语句的红色波浪线报错信息已消失不见,鼠标移到 getRandomNumber 这个方法上,也可以看到 VSCode 出现了该方法的类型提示,非常方便。

TIP

如果 VSCode 未能及时更新该包的类型,依然提示红色波浪线,可以重启 VSCode 再次查看。

再次运行 npm run build 命令构建调试项目,这一次顺利通过编译:

bash
 npm run build

> hello-vue3@0.0.0 build
> vue-tsc --noEmit && vite build

vite v2.9.15 building for production...
 42 modules transformed.
dist/assets/logo.03d6d6da.png             6.69 KiB
dist/index.html                           0.42 KiB
dist/assets/home.9a123f29.js              2.01 KiB / gzip: 1.01 KiB
dist/assets/logo.db8b6a93.js              0.12 KiB / gzip: 0.13 KiB
dist/assets/TransferStation.25db7d3e.js   0.29 KiB / gzip: 0.22 KiB
dist/assets/bar.0e9da4c4.js               0.53 KiB / gzip: 0.37 KiB
dist/assets/bar.09e673fa.css              0.22 KiB / gzip: 0.18 KiB
dist/assets/home.6bd02f2a.css             0.62 KiB / gzip: 0.33 KiB
dist/assets/index.60726771.css            0.47 KiB / gzip: 0.29 KiB
dist/assets/index.aebbe022.js             79.87 KiB / gzip: 31.80 KiB

生成 DTS Bundle

初始化项目生成 DTS 文件 ,其实已经走完一个 npm 包的完整开发流程了,是可以提交发布了,但在发布之前,先介绍另外一个生成 DTS 文件的方式,可以根据实际情况选择使用。

请注意这里使用了 DTS Bundle 来称呼类型声明文件,这是因为直接使用 tsc 命令生成的 DTS 文件,是和源码目录的文件数量挂钩的,可以留意到在上一小节使用 tsc 命令生成声明文件后,在 hello-lib 项目中:

  • src 源码目录有 index.ts 和 utils.ts 两个文件
  • dist 输出目录也对应生成了 index.d.ts 和 utils.d.ts 两个文件

在一个大型项目里,源码的目录和文件非常多,意味着 DTS 文件也是非常多,这样的输出结构并不是特别友好。

在讲 npm 包对类型声明 主流的做法 的时候,提到了 Vue 响应式 API 的 npm 包是提供了一个完整的 DTS 文件,它包含了所有 API 的类型声明信息:

bash
./node_modules/@vue/reactivity/dist/reactivity.d.ts

这种将多个模块的文件内容合并为一个完整文件的行为通常称之为 Bundle ,本小节将介绍如何生成这种 DTS Bundle 文件。

继续回到 hello-lib 这个 npm 包项目,由于 tsc 本身不提供类型文件的合并,所以需要借助第三方依赖来实现,比较流行的第三方包有: dts-bundle-generatornpm-dtsdts-bundledts-generator 等等。

之前笔者在为公司开发 npm 工具包的时候都对它们进行了一轮体验,鉴于实际开发过程中遇到的一些编译问题,在这里选用问题最少的 dts-bundle-generator 进行开发演示,请先安装到 hello-lib 项目的 devDependencies :

bash
npm i -D dts-bundle-generator

dts-bundle-generator 支持在 package.json 里配置一个 script ,通过命令的形式在命令行生成 DTS Bundle ,也支持通过 JavaScript / TypeScript 编写函数来执行文件的生成,鉴于实际开发过程中使用函数生成 DTS Bundle 的场景比较多(例如 Monorepo 会有生成多个 Bundle 的使用场景),因此这里以函数的方式进行演示。

TIP

在使用 Git 等版本控制系统时,如果多个独立项目之间有关联,会把这些项目的代码都存储在同一个代码仓库集中管理,此时这个大型代码仓库就被称之为 Monorepo (其中 Mono 表示单一, Repo 是存储库 Repository 的缩写),当下许多大型项目都基于这种方法管理代码, Vue 3 在 GitHub 的代码仓库也是一个 Monorepo 。

请在 hello-lib 的根目录下,创建一个与 src 源码目录同级的 scripts 目录,用来存储源码之外的脚本函数。

将以下代码保存到 scripts 目录下,命名为 buildTypes.mjs :

js
// scripts/buildTypes.mjs
import { writeFileSync } from 'fs'
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
import { generateDtsBundle } from 'dts-bundle-generator'

async function run() {
  // 默认情况下 `.mjs` 文件需要自己声明 __dirname 变量
  const __filename = fileURLToPath(import.meta.url)
  const __dirname = dirname(__filename)

  // 获取项目的根目录路径
  const rootPath = resolve(__dirname, '..')

  // 添加构建选项
  // 插件要求是一个数组选项,支持多个入口文件
  const options = [
    {
      filePath: resolve(rootPath, `./src/index.ts`),
      output: {
        noBanner: true,
      },
    },
  ]

  // 生成 DTS 文件内容
  // 插件返回一个数组,返回的文件内容顺序同选项顺序
  const dtses = generateDtsBundle(options, {
    preferredConfigPath: resolve(rootPath, `./tsconfig.json`),
  })
  if (!Array.isArray(dtses) || !dtses.length) return

  // 将 DTS Bundle 的内容输出成 `.d.ts` 文件保存到 dist 目录下
  // 当前只有一个文件要保存,所以只取第一个下标的数据
  const dts = dtses[0]
  const output = resolve(rootPath, `./dist/index.d.ts`)
  writeFileSync(output, dts)
}
run().catch((e) => {
  console.log(e)
})

接下来打开 hello-lib 的 package.json 文件,添加一个 build:types 的 script ,并在 build 命令中通过 && 符号设置为继发执行任务,当前所有的 scripts 如下:

json
{
  "scripts": {
    "build": "vite build && npm run build:types",
    "build:types": "node scripts/buildTypes.mjs"
  }
}

TIP

继发执行:只有前一个任务执行成功,才继续执行下一个任务,任务与任务之间使用 && 符号连接。

接下来再运行 npm run build 命令,将在执行完 Vite 的 build 任务之后,再继续执行 DTS Bundle 的文件生成,可以看到现在的 dist 目录变成了如下,只会生成一个 .d.ts 文件:

bash
hello-lib
└─dist
  ├─index.cjs
  ├─index.d.ts
  ├─index.min.js
  └─index.mjs

现在 index.d.ts 文件已经集合了源码目录下所有的 TS 类型,变成了如下内容:

ts
// dist/index.d.ts
/**
 * 生成随机数
 * @param min - 最小值
 * @param max - 最大值
 * @param roundingType - 四舍五入类型
 * @returns 范围内的随机数
 */
export declare function getRandomNumber(
  min?: number,
  max?: number,
  roundingType?: 'round' | 'ceil' | 'floor'
): number
/**
 * 生成随机布尔值
 */
export declare function getRandomBoolean(): boolean

export {}

对于大型项目,将 DTS 文件集合为 Bundle 输出是一种主流的管理方式,非常建议使用这种方式来为 npm 包生成类型文件。

添加说明文档

作为一个完整的 npm 包,应该配备一份操作说明给使用者阅读,复杂的文档可以使用 VitePress 等文档程序独立部署,而简单的项目则只需要完善一份 README 即可。

请创建一个名为 README.md 的 Markdown 文件在项目根目录下,与 src 源码目录同级,该文件的文件名 README 推荐使用全大写,这是开源社区主流的命名方式,全大写的原因是为了与代码文件进行直观的区分。

编写 README 使用的 Markdown 是一种轻量级标记语言,可以使用易读易写的纯文本格式编写文档,以 .md 作为文件扩展名,当代码托管到 GitHub 仓库或者发布到 npmjs 等平台时, README 文件会作为项目的主页内容呈现。

为了方便学习,这里将一些常用的 Markdown 语法与 HTML 代码对比如下,可以看到书写方面非常的简洁:

Markdown 代码HTML 代码
# 一级标题<h1>一级标题</h1>
## 二级标题<h2>二级标题</h2>
### 三级标题<h3>三级标题</h3>
**加粗文本**<span style="font-weight: bold;">加粗文本</span>
[链接文本](https://example.com)<a href="https://example.com">链接文本</a>

更多的 Markdown 语法建议在 Markdown 教程网站 上学习。

下面附上一份常用的 README 模板:

md
# 项目名称

写上项目用途的一句话简介。

## 功能介绍

1. 功能 1 一句话介绍
2. 功能 2 一句话介绍
3. 功能 3 一句话介绍

## 在线演示

如果有部署在线 demo ,可放上 demo 的访问地址。

## 安装方法

使用 npm : `npm install package-name`

使用 CDN : `https://example.com/package-name`

## 用法

告诉使用者如何使用 npm 包。

## 插件选项

如果 npm 包是一个插件,并支持传递插件选项,在这里可以使用表格介绍选项的作用。

| 选项名称 |  类型  |    作用    |
| :------: | :----: | :--------: |
|   foo    | string | 一句话介绍 |
|   bar    | number | 一句话介绍 |

更多内容请根据实际情况补充。

拥有完善的使用说明文档,会让 npm 包更受欢迎!

发布 npm 包

一个 npm 包开发完毕后,就可以进入发布阶段了,这一小节将讲解如何注册 npm 账号并发布到 npmjs 平台上供其他开发者下载使用。

TIP

在操作 npm 包发布之前,请先运行 npm config rm registry 命令取消 npm 镜像源的绑定,否则会发布失败,在 npm 包发布后,可以再重新 配置镜像源

注册 npm 账号

在发布 npm 包之前,请先在 npm 官网上注册一个账号:点击注册

接下来需要在命令行上登录该账号以操作发布命令,打开命令行工具,输入以下命令进行登录:

bash
npm login

按照命令行的提示输入在 npmjs 网站上注册的账号和密码即可完成登录,可以通过以下命令查看当前登录的账号名称,验证是否登录成功:

bash
npm whoami

在登录成功之后,命令行会记住账号的登录状态,以后的操作就无需每次都执行登录命令了。

TIP

以上操作也可以实用 npm adduser 命令代替,直接在命令行完成注册和登录。

将包发布到 npmjs

在 npm 上发布私有包需要进行付费,因此这里只使用公共包的发布作为演示和讲解,如果开发的是公司内部使用的 npm 包,只要源代码是私有仓库,也可以使用这种方式来发布,当前在这样做之前请先获得公司的同意。

对于一个普通命名的包,要发布到 npmjs 上非常简单,只需要执行 npm 包管理器自带的一个命令即可:

bash
npm publish

它默认会将这个包作为一个公共包发布,如果包名称合法并且没有冲突,则发布成功,可以在 npmjs 查询到,否则会返回错误信息告知原因,如果因为包名冲突导致的失败,可以尝试修改别的名称再次发布。

如果打算使用像 @vue/cli@vue/compiler-sfc 这样带有 @scope 前缀的作用域包名,需要先在 npmjs 的 创建新组织 页面创建一个组织,或者确保自己拥有 @scope 对应的组织发布权限。

@scope 作用域包默认会作为私有包发布,因此在执行发布命令的时候还需要加上一个 --access 选项,将其指定为 public 允许公开访问才可以发布成功:

bash
npm publish --access public

当前的 hello-lib 项目已发布到 npmjs ,可以查看该包的主页 @learning-vue3/lib ,也可以通过 npm 安装到项目里使用了:

bash
npm i @learning-vue3/lib

并且发布到 npmjs 上的包,都同时获得热门 CDN 服务的自动同步,可以通过包名称获取到 CDN 链接并通过 <script /> 标签引入到 HTML 页面里:

bash
# 使用 jsDelivr CDN
https://cdn.jsdelivr.net/npm/@learning-vue3/lib

# 使用 UNPKG CDN
https://unpkg.com/@learning-vue3/lib

此时 CDN 地址对应的 npm 包文件内容,就如前文所述,调用了 package.json 里 browser 字段指定的 UMD 规范文件 dist/index.min.js

给 npm 包打 Tag

细心的开发者还会留意到,例如像 Vue 这样的包,在 npmjs 上的 版本列表 里有 Current Tags 和 Version History 的版本分类,其中 Version History 是默认的版本发布历史列表,而 Current Tags 则是在发布 npm 包的时候指定打的标签。

标签的好处是可以让使用者无需记住对应的版本号,而是使用一些更具备语义化的单词来安装指定版本,例如:

bash
# 安装最新版的 Vue 3 ,即截图里对应的 3.2.40 版本
npm i vue@latest

# 安装最新版的 Vue 2 ,即截图里对应的 2.7.10 版本
npm i vue@v2-latest

# 如果后续有功能更新的测试版,也可以通过标签安装
npm i vue@beta

除了减少寻找版本号的麻烦外,一旦后续有版本更新,再次使用相同的标签安装,可以重新安装到该标签对应的最新版本,例如从 1.0.0-beta.1 升级到 1.0.0-beta.2 ,可以使用 @beta 标签再次安装来达到升级的目的。

在标签列表里,有一个 latest 的标签是发布 npm 包时自带的,对应该包最新的正式版本,安装 npm 包时如果不指定标签,则默认使用 latest 标签,以下两个安装操作是等价的:

bash
# 隐式安装 latest 标签对应的版本
npm i vue

# 显式安装 latest 标签对应的版本
npm i vue@latest

同样的,当发布 npm 包时不指定标签,则该版本也会在发布后作为 @latest 标签对应的版本号。

其他标签则需要在发布时配合发布命令,使用 --tag 作为选项手动指定,以下命令将为普通包打上名为 alpha 的 Tag :

bash
npm publish --tag alpha

同理,如果是 @scope 作用域包也是在使用 --access 选项的情况下,继续追加一条 --tag 选项指定包的标签。

bash
npm publish --access public --tag alpha

TIP

请注意,如果是 Alpha 或者 Beta 版本,通常会在版本号上增加 -alpha.0-alpha.1 这样的 版本标识符,以便在发布正式版本的时候可以使用无标识符的相同版本号,以保证版本号在遵循 升级规则 下的连续性。

Released under the MIT License.