用来检测 npm 包名是否符合标准的 npm 包 | validate-npm-package-name

533 阅读4分钟

前言

本文将阅读 validate-npm-package-name 包的源码,该 npm 包主要用来检测 npm 包名是否符合标准。【本文参加了每周一起学习200行源码共读活动

源码阅读

严格模式

'use strict'

代码首行开启了严格模式

正则表达式

var scopedPackagePattern = new RegExp('^(?:@([^/]+?)[/])?([^/]+?)$')

为了读懂这句正则表达式,我仔细阅读了 MDN 中的正则表达式指南。下面对这句正则表达式中用到的特殊字符做下介绍。

^

匹配输入的开始。如果多行标志被设置为 true,那么也匹配换行符后紧跟的位置。例如,/^A/ 并不会匹配 "an A" 中的 'A',但是会匹配 "An E" 中的 'A'。

const pkgName = "@vue/cli-service";
console.log(pkgName.match(/^@/)); // ['@', index: 0, input: '@vue/cli-service', groups: undefined]
console.log(pkgName.match(/^v/)); // null

上面示例中,match 方法会返回一个数组,它包括整个匹配结果,和通过捕获组匹配到的结果,如果没有匹配到则返回 null 。

(x)

捕获组: 匹配 x 并记录匹配项。例如,/(foo)/ 匹配并记录 "foo bar" 中的 'foo' 。

  • 用 () 将其他表达式包含,可以使被包含的表达式组成一个整体,在被修饰匹配次数时,可作为整体被修饰。
  • 用 () 包含的表达式,所匹配到的内容将单独作记录,匹配过程中或结束后可以被获取。
  • 每一对 () 会分配一个编号,使用 () 的捕获根据左括号的顺序从 1 开始自动编号。捕获元素编号为零的第一个捕获是由整个正则表达式模式匹配的文本。
const pkgName = "@vue/cli-service";
console.log(pkgName.match(/^(@)/)); // ['@', '@', index: 0, input: '@vue/cli-service', groups: undefined]

上面示例中,match 方法返回了包含 2 个相同元素的数组。索引为 0 的 '@' 是由整个正则表达式模式匹配的文本;索引为 1 的'@'是由 /^(@)/ 中的 (@) 捕获的文本。

(?:x)

非捕获组: 匹配 x 但不记录匹配。例如,/(?:foo)/ 匹配但不记录 "foo bar" 中的 'foo' 。

  • 使用 (?:) 包含其他表达式,可使被包含的表达式组成一个整体,在被修饰匹配次数时,可作为整体被修饰。
  • 非捕获组不记录所匹配的内容,比捕获组更节约内存资源。
const pkgName = "@vue/cli-service";
console.log(pkgName.match(/^(?:@)/)); // ['@', index: 0, input: '@vue/cli-service', groups: undefined]

上面示例中,match 方法返回了只包含 1 个元素的数组。索引为 0 的 '@' 是由整个正则表达式模式匹配的文本,而 /^(?:@)/ 中的 (?:@) 是非捕获组,并不会由 match 方法返回。

[^x]

一个反向字符集。匹配任何没有包含在方括号中的字符,也可以使用破折号(-)来指定一个字符范围。例如,/[^abc]//[^a-c]/ 是一样的,都匹配 "brisket" 中的 'r',也匹配 "chop" 中的 'h'。

const pkgName = "@vue/cli-service";
console.log(pkgName.match(/^(?:@([^/]))/)); // ['@v', 'v', index: 0, input: '@vue/cli-service', groups: undefined]

上面示例中,match 方法返回了包含两个元素的数组。索引为 0 的 '@v' 是由整个正则表达式匹配的文本;索引为 1 的 'v' 是由 /^(?:@([^/]))/ 中的 ([^/]) 捕获的文本,而 [^/] 是表示匹配不是 '/' 的任何字符。

+

匹配前面一个表达式 1 次或者多次。等价于 {1,}。例如,/a+/ 会匹配 "candy" 中的 'a' 和 "caaaaaaandy" 中所有的 'a',但是在 "cndy" 中不会匹配任何内容。

const pkgName = "@vue/cli-service";
console.log(pkgName.match(/^(?:@([^/]+))/)); // ['@vue', 'vue', index: 0, input: '@vue/cli-service', groups: undefined]

上面示例中,match 方法返回了包含两个元素的数组。索引为 0 的 '@vue' 是由整个正则表达式匹配的文本;索引为 1 的 'vue' 是由 /^(?:@([^/]+))/ 中的 ([^/]+) 捕获的文本,而 [^/]+ 是表示匹配不是 '/' 的任何字符 1 次或者多次。

?

匹配前面一个表达式 0 次或者 1 次。等价于 {0,1}。例如,对 "123abc" 使用 /\d+/ 将会匹配 "123",而使用 /\d+?/ 则只会匹配到 "1"。

const pkgName = "@vue/cli-service";
console.log(pkgName.match(/^(?:@([^/]+?))/)); // ['@v', 'v', index: 0, input: '@vue/cli-service', groups: undefined]

上面示例中,match 方法返回了包含两个元素的数组。索引为 0 的 '@v' 是由整个正则表达式匹配的文本;索引为 1 的 'v' 是由 /^(?:@([^/]+?))/ 中的 ([^/]+?) 捕获的文本,+? 连用表示尽量只匹配1次,最多可匹配任意次,相当于 {1, }?

[x]

一个字符集合。匹配方括号中的任意字符,包括转义序列,也可以使用破折号(-)来指定一个字符范围。例如,/[abcd]//[a-d]/ 是一样的,都会匹配 "brisket" 中的 'b',也都匹配 "city" 中的 'c'。/[a-z.]+//[\w.]+/ 与字符串 "test.i.ng" 匹配。

const pkgName = "@vue/cli-service";
console.log(pkgName.match(/^(?:@([^/]+?)[/])/)); // ['@vue/', 'vue', index: 0, input: '@vue/cli-service', groups: undefined]

上面示例中,match 方法返回了包含两个元素的数组。索引为 0 的 '@vue/' 是由整个正则表达式匹配的文本;索引为 1 的 'vue' 是由 /^(?:@([^/]+?)[/])/ 中的 ([^/]+?) 捕获的文本,([^/]+?) 为了配合后面的 [/] 触发了 +? 的多次匹配。

$

匹配输入的结束。如果多行标志被设置为 true,那么也匹配换行符前的位置。例如,/t$/ 并不会匹配 "eater" 中的 't',但是会匹配 "eat" 中的 't'。

const pkgName = "@vue/cli-service";
console.log(pkgName.match(/^(?:@([^/]+?)[/])?([^/]+?)$/)); // ['@vue/cli-service', 'vue', 'cli-service', index: 0, input: '@vue/cli-service', groups: undefined]

上面示例中,match 方法返回了包含三个元素的数组。索引为 0 的 '@vue/cli-service' 是由整个正则表达式匹配的文本;索引为 1 的 'vue' 是由倒数第 2 个 ? 前面部分的 ([^/]+?) 捕获的文本;索引为 2 的 'cli-service' 是由 $ 前面的 ([^/]+?) 捕获的文本,这部分的 ([^/]+?) 为了配合后面的 $ 触发了 +? 的多次匹配。

builtins

var builtins = require('builtins')

这里引入了一个名为 builtins 的 npm 包,官方介绍是 List of node.js builtin modules,即 node.js 的内置模块列表。

blacklist

var blacklist = [
  'node_modules',
  'favicon.ico'
]

意思是不能使用黑名单列表中的名称作为 npm 包名。

全部源码

// 开启严格模式
'use strict'

// 名称校验正则表达式
var scopedPackagePattern = new RegExp('^(?:@([^/]+?)[/])?([^/]+?)$')
// 引入 node.js 的内置模块名称列表
var builtins = require('builtins')
// 名称黑名单
var blacklist = [
  'node_modules',
  'favicon.ico'
]

// 对外输出变量
var validate = module.exports = function (name) {
  // 警告信息数组
  var warnings = []
  // 错误信息数组
  var errors = []

  // 名称不能为 null
  if (name === null) {
    errors.push('name cannot be null')
    return done(warnings, errors)
  }

  // 名称不能为 undefined
  if (name === undefined) {
    errors.push('name cannot be undefined')
    return done(warnings, errors)
  }

  // 名称必须是字符串类型
  if (typeof name !== 'string') {
    errors.push('name must be a string')
    return done(warnings, errors)
  }

  // 名称长度不能少于 0 位
  if (!name.length) {
    errors.push('name length must be greater than zero')
  }

  // 名称不能以点开头----在特殊字符之前的反斜杠表示下一个字符不是特殊字符,应该按照字面理解。
  if (name.match(/^\./)) {
    errors.push('name cannot start with a period')
  }

  // 名称不能以下划线开头
  if (name.match(/^_/)) {
    errors.push('name cannot start with an underscore')
  }

  // 名称首尾不能为空格
  if (name.trim() !== name) {
    errors.push('name cannot contain leading or trailing spaces')
  }

  // 名称不能使用黑名单列表中的任何一项
  // No funny business
  blacklist.forEach(function (blacklistedName) {
    if (name.toLowerCase() === blacklistedName) {
      errors.push(blacklistedName + ' is a blacklisted name')
    }
  })

  // Generate warnings for stuff that used to be allowed

  // 名称不宜使用 node.js 的内置模块名称列表中的任何一项
  // core module names like http, events, util, etc
  builtins.forEach(function (builtin) {
    if (name.toLowerCase() === builtin) {
      warnings.push(builtin + ' is a core module name')
    }
  })

  // 名称长度不宜超过 214 位
  // really-long-package-names-------------------------------such--length-----many---wow
  // the thisisareallyreallylongpackagenameitshouldpublishdowenowhavealimittothelengthofpackagenames-poch.
  if (name.length > 214) {
    warnings.push('name can no longer contain more than 214 characters')
  }

  // 名称不宜包含大写字母
  // mIxeD CaSe nAMEs
  if (name.toLowerCase() !== name) {
    warnings.push('name can no longer contain capital letters')
  }

  // 名称不宜包含某些特殊字符----对于点(.)和星号(*)这样的特殊符号在字符集中没有特殊的意义,不必进行转义
  if (/[~'!()*]/.test(name.split('/').slice(-1)[0])) {
    warnings.push('name can no longer contain special characters ("~\'!()*")')
  }

  // 名称需要为 URL 友好字符串
  if (encodeURIComponent(name) !== name) {
    // 如果是作用域包,例如@vue/cli-service----作用域前面带有@符号,后面带有斜杠
    // Maybe it's a scoped package name, like @user/package
    // 检索并返回一个匹配正则表达式的结果(数组或null)
    var nameMatch = name.match(scopedPackagePattern)
    // 如果匹配成功
    if (nameMatch) {
      var user = nameMatch[1] // 作用域名----示例中的 vue
      var pkg = nameMatch[2]  // 包名----示例中的 cli-service
      if (encodeURIComponent(user) === user && encodeURIComponent(pkg) === pkg) {
        return done(warnings, errors)
      }
    }

    errors.push('name can only contain URL-friendly characters')
  }

  return done(warnings, errors)
}

validate.scopedPackagePattern = scopedPackagePattern

// 判断并输出错误及警告信息
var done = function (warnings, errors) {
  var result = {
    // 对新包名有效
    validForNewPackages: errors.length === 0 && warnings.length === 0,
    // 对旧包名有效
    validForOldPackages: errors.length === 0,
    // 警告信息列表
    warnings: warnings,
    // 错误信息列表
    errors: errors
  }

  // 警告信息列表为空就从返回对象中删除 warnings 属性
  if (!result.warnings.length) delete result.warnings
  // 错误信息列表为空就从返回对象中删除 errors 属性
  if (!result.errors.length) delete result.errors
  
  return result
}

其余代码没什么好解释的,主要是些 if 判断。

后记

正则表达式部分可能有些啰嗦了,不过也是水平所限。其实,直到这次阅读源码,我才强迫自己认认真真地去了解正则表达式。之前只是项目里面需要哪种就去搜一下,并没有真正弄懂那些符号究竟是什么意思。查漏补缺也是阅读源码意义所在的一部分吧。

参考文档

1、npm 官网 validate-npm-package-name 包

2、npm 官网 builtins 包

3、MDN 中严格模式的介绍

4、MDN 中正则表达式的介绍

5、Node.js 的模块加载方法

6、npm 包名称规范

7、npm 新包名规则

8、如何使用作用域包