前端包管理进阶:通用函数库与组件库打包实战

npm 回顾

  • 回顾 npm 基本概念
  • 关于包的概念

回顾 npm 基本概念

node package manager,翻译成中文就是 node 包管理器。

为什么现代前端开发中需要包管理器 ?

  • 因为在进行项目开发的时候,往往会需要用到别人现成的代码,但是这里就会涉及到一个问题,如果采用传统的方式,那么每次我们在引入一个包的时候,需要从官网下载代码,解压,然后放入到自己的项目,这样的做法太过原始,非常繁琐
  • 在现代开发中,引用的包往往存在复杂的依赖关系,例如模块 A 依赖于模块 B,模块 B 又依赖于模块 C,如果让开发者来管理这种依赖,非常容易出错也很麻烦。

因此包管理器诞生了,包管理器就是专门用于管理软件包、库以及相互之间的依赖关系的一种工具。

一般来讲,一门成熟的语言,一定会有配套的包管理器:

  • Node.js : npm (Node Package Manager)
  • Python : pip (Pip Installs Packages)
  • Ruby : rubygems (Ruby Gems)
  • Java : Maven (Maven Repository)
  • PHP : Composer (Dependency Manager for PHP)
  • Rust : Cargo (Rust's Package Manager)
  • Go : Go mod (Go's Package Manager)

npm 实际上是由 3 个部分组成:

  • 网站:也就是 npm 的官网:www.npmjs.com/ ,注册账号、搜索某一个包查看这个包(某一个插件没有官网那种)的说明
  • CLI(command line interface):所谓命令行接口,就是在控制台输入命令来进行交互。这个是我们平时和 npm 打交道最多的方式,我们在控制台能够输入一些命令(npm i、 npm init)进行交互。
  • registry:这个就是 npm 对应的大型仓库,上传的包都会存储到这个仓库里面。

关于包的概念

究竟什么是包(package)?

  • 一个目录就是一个包 ?
  • 包和 module 有什么区别 ?
  • public package、private package、scope package ?

从软件工程的角度来讲,包是一种组织代码结构的方式,一般来讲,一个包提供了一个功能来解决某一个问题,一般一个包会将相关的所有目录和文件放到一个独立的文件夹中,并且通过一个特殊的文件(package.json)来描述这个包。

另外,如果你要向 npm 发布包,npm 要求你必须要有 package.json 这个文件。

module 翻译成中文叫做模块。一般来讲我们会将一个单独的 JS 文件称之为一个模块(module),这个模块通过会包含一个或多个变量、函数、类、对象的导出。模块是一个独立的单元,可以被其他模块导入并使用。

例如:

bash 复制代码
my-package/
|-- lib/
|   |-- string-utils.js
|-- package.json
|-- README.md

在上面的示例中,my-package 就是一个包,string-utils.js 就是一个模块。

一个包可以分为 scoped 和 unscoped,翻译成中文就是"作用域包"和"非作用域包"。

  • scoped package(作用域包):必须要以 @ 符号开头,后面跟上你的作用域名称,接下来一个斜杠,最后是包名。@scope-name/package-name,其实你在前面的学习中也是接触过的:@vue/cli、@vue/runtime-core、@vue/shared

    • 针对这种作用域包,我们在安装的时候,就需要将作用域名写全
    bash 复制代码
    npm i @vue/cli -g
    • 包括在被引入的时候,也需要将作用域名写全
    js 复制代码
    const mypackage = require("@myorg/mypackage");
    • 可以避免重名的情况,这个作用域名可以充当一个命名空间
    • 通过作用域名往往也能表达某一系列包是属于某一个组织
  • unscoped package(非作用域包):非作用域包由于没有特定的作用域,因此你在发布的时候一定要保证你的包名是全局唯一的。常见的非作用域包也很多:lodash、axios

一个包还可以分为 public 和 private,翻译成中文就是"公共包"和"私有包"。

  • public package :公共包是在 npm 注册表中公开发布的包,任何人都可以搜索、查看和安装这些包。公共包在发布时默认为开源许可证(如 MITBSDApache 等),这意味着其他人可以自由地查看源代码、修改代码并在自己的项目中使用。当你希望与广泛的开发者社区共享你的代码并允许他们参与到项目中时,可以选择发布为公共包。
  • private package :私有包是在 npm 注册表中非公开发布的包,它们只能被特定的用户或团队成员搜索、查看和安装。私有包通常用于存储企业内部的代码和资源,或者在开发过程中尚未准备好向公众发布的项目。要发布和使用私有包,你需要拥有一个 npm 付费账户 并将包的 private 属性设置为 trueprivate package 通常都是 scoped package

npm 进阶指令

平时用的最多的指令可能就 3 个:

  • npm init -y
  • npm install xxx(npm i xxx)
  • npm uninstall xxx(npm rm xxx)

所有的指令实际上可以在 npm 官网上面看到的:docs.npmjs.com/cli/v9/comm...

查看相关信息的指令

  • npm version:查看当前 npm cli 的详细信息,相比 npm -v 显示的信息要更丰富一些。
  • npm root:查找本地或者全局安装的包的根目录。查看全局的包目录的话,需要添加 -g
  • npm info:查看某一个包的详细信息,这里所说的信息是指包版本、依赖项、作者、描述等信息,便于开发者选择合适的包。
  • npm search:这个命令是对包进行搜索,提供一个关键字,会搜索出所有和关键字相关的包
  • npm outdated:该命令可以用于检查当前项目中的依赖包是否过时,以及当前可用的最新版本。
  • npm ls:可以罗列出当前项目安装的依赖包以及依赖包下层的依赖。通过 --depth 0/1/2 来进行层级的调整,例如 npm ls --depth 1 就能够罗列出当前依赖以及当前依赖下一层所需的依赖。如果是 npm ls -g 罗列出全局的包

配置相关指令

  • npm config

一般来讲,一个成熟的工具,是一定会支持配置的,因为不可能把所有的东西写死,很多时候有一些东西需要让用户根据他们的需求进行配置。

npm 也如此,npm 的配置可以从三个地方:

  • conmand line
  • 环境变量
  • .npmrc 文件

.npmrc 文件用的最多就是拿来配置仓库镜像,也可以通过命令行指令去改:

bash 复制代码
npm config get registry
npm config set registry=xxxxx
npm config list

我们还可以通过 npm config edit 进入到编辑模式,里面能够编辑各种配置项目

建立软链接

npm link

该命令用于针对一个包(a)创建一个快捷方式,其他项目假设要用到这个包(a),因为有快捷方式,其他项目通过快捷方式可以快速的链接到这个包,不需要每次 a 这个包重新发布,其他项目重新安装

下面是一个例子

假设我们有 a 和 b 两个包,这两个包都是独立发布的,现在 b 包中要用到 a 包里面的东西

首先针对 a 包做 link

bash 复制代码
npm link

运行上面的命令之后,就会在全局的 node_modules 下面创建一个软链接,指向 a

回头在 b 包里面要用到 a 包的时候,直接通过 link 去进行链接

bash 复制代码
npm link a

当开发完成后,我们需要断开链接,通过 npm unlink 来断开

bash 复制代码
cd /path/to/b
npm unlink a

假设现在 a 项目已经没有被任何项目所链接,那么我们就可以将其从全局 node_modules 里面删除

bash 复制代码
cd /path/to/a
npm unlink -g a

缓存相关的指令

npm cache

当我们安装、更新或者卸载包的时候,npm 会将这些包的 tarball 文件缓存到本地磁盘上,有助于加速将来的安装过程。之后再次安装的时候,可以直接从缓存文件中去获取,无需再次从远程仓库下载。

tarball 文件是一种压缩文件格式,通常用于在 UnixLinux 系统中打包和分发源代码、二进制文件或其他文件。tarball 文件的扩展名通常为 .tar.gz.tgz ,它们是通过将多个文件打包成一个 .tar 文件(使用 tar 工具),然后将该文件进行 gzip 压缩而创建的。

npm 中,tarball 文件通常用于将包的所有文件(源代码、二进制文件、文档等)打包成一个单独的文件,以便在安装或更新包时从 npm 仓库(如 registry.npmjs.org )下载。当你运行 npm install <package> 时,npm 会从远程仓库下载包的 tarball 文件,然后在本地解压缩和安装该包。

  • 清理缓存:npm cache clean,在较新的版本中,目前已经不推荐直接清理缓存,而是推荐 npm cache verify 去验证缓存。
  • 验证缓存:npm cache verify,验证缓存的完整性,检查缓存是否已经过期、无效、损坏,也就是说,验证缓存是否有用,如果没用再进行删除。
  • 添加缓存:npm cache add <package> ,一般不需要手动添加缓存,因为在安装包的时候就会自动添加缓存
  • 查看缓存:npm cache ls 查看 npm 缓存的所有的包
  • 查看缓存目录:npm config get cache

包的更新相关的指令

  • npm update:该指令用于更新当前项目中的依赖包,npm 会检查是否有新的版本,如果有就会进行更新,但是注意,在更新的时候会去满足 package.json 里面的版本范围规定(^ ~)

    • 也可以指定要更新某一个包
    bash 复制代码
    npm update package_name
  • npm audit:用于检查当前项目中的依赖,哪些依赖有漏洞

    • 在审计的同时,可以直接进行修复,通过命令 npm audit fix
  • npm dedupe:该命令能够优化项目里面的依赖树的结构,示例如下:

bash 复制代码
a
+-- b <-- depends on c@1.0.x
|   `-- c@1.0.3
`-- d <-- depends on c@~1.0.9
    `-- c@1.0.10

在上面的依赖树中,a 依赖 b 和 d,然而 b 和 d 都同时依赖 c,这种情况下,依赖是能够进行优化的

bash 复制代码
a
+-- b
+-- d
`-- c@1.0.10

注意,npm dedupe 无法将所有重复的包进行消除,因为在有些时候,不同的依赖项就是需要不同版本的相同依赖,不过 npm dedupe 会尽量去消除重复的包。

  • npm prune:用于删除没有在 package.json 文件中列出的依赖包,该命令可以帮助我们清理 node_modules,删除不再需要的依赖。

提供帮助

上面我们已经介绍了很多的指令了,但是仍然没有覆盖所有的指令,而且不同指令还支持不同的配置参数。

  • npm help:帮助指令,可以查看 npm 中提供的所有指令
  • npm help xxx 来查看某个指令具体的一些信息

包的说明文件

所谓包的说明文件,也就是 package.json,当我们使用 npm init 去初始化一个项目的时候,就会自动的生成一个 package.json 文件。

关于 package.json,官网是有详细的配置项说明的:docs.npmjs.com/cli/v9/conf...

包的说明信息相关的配置

  • name:包的名字,必须是唯一的

  • version:包的版本号,一般由三个数字组成,格式为 x.y.z

    • x 代表主版本号(Major Version),一般是你的软件包发生了重大变化或者不兼容的升级,那么需要增加主版本号
    • y 代表次版本号(Minor Version),当你的软件包增加了新的功能或者新的特性,需要增加次版本号
    • z 代表修订号(Patch Version),当你的软件包进行 bug 的修复,性能的优化,较小的改动,需要增加修订号
  • description:包的描述信息

  • keyword:包的关键词,用于搜索和分类的

json 复制代码
{
  ...
  keyword: ['good', 'tools']
}
  • author:作者信息
json 复制代码
"author": {
  "name": "John Doe",
  "email": "john.doe@example.com",
  "url": "https://example.com/johndoe"
}
  • contributors:包的贡献者名单
  • license:包的许可证信息,指定包的开源类型
  • repository:包的源代码仓库信息,可以再提供一个 git 地址
json 复制代码
"repository": {
  "type": "git",
  "url": "https://github.com/username/my-awesome-package.git"
}
  • engines:用来指定项目需要的 node 版本以及 npm 版本,从而避免用户在使用你的包的时候出现一些因为版本不支持而产生的问题。
json 复制代码
"engines": {
  "node": ">=12.0.0",
  "npm": ">=6.0.0"
},

包执行相关配置

  • main:代表包的入口文件

  • browser:该选项表示如果是在浏览器环境下,可以替换一些特定模块或者文件。

    • 指定浏览器的入口文件
    json 复制代码
    {
      "main": "index.js",
      "browser": "browser.js"
    }

    上面的配置表面在 node 环境下,index.js 为入口文件,如果是浏览器环境,使用 browser.js 作为入口文件

    • 替换特定的模块
    json 复制代码
    {
      "browser": {
        "./node-version.js": "./browser-version.js"
      }
    }
    • 排除某些模块
    json 复制代码
    {
      "browser": {
        "fs": false
      }
    }
  • scripts:配置你的可执行命令

    json 复制代码
    "scripts": {
      "start": "node index.js",
      "test": "jest",
      "build": "webpack",
      "lint": "eslint src",
      "format": "prettier --write src"
    }

    脚本是可以配置生命周期钩子方法,使用到的关键词为 pre 和 post,pre 代表在执行某个脚本之前,post 代表在执行某个脚本之后

    json 复制代码
    "scripts": {
      "prestart": "npm run build",
      "start": "node index.js",
      "test": "mocha",
      "build": "webpack",
      "lint": "eslint src",
      "format": "prettier --write src",
      "posttest": "npm run lint && npm run format"
    }
    • prestart:代表在执行 start 之前,先运行 build
    • posttest:代表在执行了 test 之后,同时运行 lint 以及 format

包的依赖信息相关配置

  • dependencies:包的依赖列表,最终打包的时候,是会将这一部分依赖打包进去。比如你的项目用到了 lodash,最终你对项目进行打包的时候,你就应该把 lodash 打包进去,所以 lodash 就应该记入到 dependencies

  • devDependencies:这个代表开发依赖,开发的时候我会用到,但是最终打包的时候不需要打包进去,webpack、eslint、typescript、sass,这一部分依赖就应该记入到 devDependencies

  • 控制依赖版本的范围:有一些符号(^ ~)是专门用来控制依赖的版本范围的,这些符号用来指定依赖在更新的时候能够更新的范围

    • ^(脱字符):表示允许更新到相同主版本号的最新版本,也就是说次版本可以变,补丁版本可以变,但是主版本不能变。举个例子:^1.2.3 更新的时候,允许的范围就是 >= 1.2.3 且 < 2.0.0
    • ~(波浪字符):表示主版本号和次版本号都必须相同,也就是说能够更新的只有补丁号。举个例子:~ 1.2.3 更新的时候,允许的范围为 >= 1.2.3 且 < 1.3.0
    • 关于版本范围的符号,其实远远不止上面两个,这个是有一套详细规则,可以参阅:docs.npmjs.com/cli/v9/conf...
  • peerDependencies:该配置项通常用于开发插件或者库的时候,表示需要与项目(这里的项目指的是使用我们插件或者库的项目)一起使用的依赖,确保这些依赖有一个合适的版本。

    • 假设你现在在开发一个 react 插件,你在开发 react 的时候肯定会涉及到使用 react 的环境,如果此时你将 react 记入到 dependencies,那么则意味着别人项目在使用你的插件的时候,也会去下载 react。这里就会存在两个问题
      • 别人既然使用你这个插件,那么说明别人也是在做 react 的开发,别人的项目自然而然已经安装了 react
      • 如果不记入到 dependencies 里面,那么又会存在因为版本不一致可能出现的兼容问题
    • peerDependencies 就是用来解决这个问题,例如我现在在开发一个 react 的插件,用到了 react 以及 react-dom
    json 复制代码
    {
      "name": "my-react-plugin",
      "version": "1.0.0",
      "peerDependencies": {
        "react": "^17.0.0",
        "react-dom": "^17.0.0"
      }
    }

    回头别人在使用你这个插件的时候,它就必须确保安装符合版本要求的依赖。否则 npm 是会给出警告。

    讲到这里,有的同学会有这样的疑问,我在开发插件的时候,直接将所有用到的依赖全部声明为 peerDependencies,如果你这么做,则意味着用户在使用你这个插件的时候,用户需要手动的去安装众多的依赖,假设你的插件有几十个依赖,那么用户就需要手动的去安装几十个依赖,这显然也是不合适的。

    因此 peerDependencies 只会记入一个插件的主要依赖(angular、react、vue)。

    下面有一些建议,可以帮助你做出决策:

    • 考虑你的插件的目标受众。如果你认为大部分使用你插件的项目都已经在使用你所依赖的库(例如 lodash ),那么将这些库声明为 peerDependencies 可能是合适的。
    • 考虑插件与依赖项之间的紧密程度。如果你的插件仅依赖于依赖项的一个小功能,而且这个功能在未来版本中不太可能发生变化,那么你可以将这个依赖项声明为 dependencies
    • 如果你决定将某些库声明为 peerDependencies,请确保在插件的文档中明确提醒用户需要安装这些库。

发布npm包

要发布自己的包到 npm 上面,大致分为如下的步骤:

  • 准备账号
  • 配置 package.json
  • 打包发布

准备账号

首先去 npm 官网注册一个账号:www.npmjs.com/

注意在注册账号的时候,把邮箱也一并设置了,方便之后接收验证码。

账号注册完毕后,就可以在控制台通过 npm login 来进行登录

还可以通过 npm profile 相关的指令来获取个人账号相关的信息

如果要退出登录,可以通过 npm logout 指令

注意,由于我们是要向 npm 官方推送包,所以需要将镜像修改为 npm 的镜像源

npm config set registry=registry.npmjs.org/

配置 package.json

  • 设置忽略文件
  • 设置模块类型

设置忽略文件

当我们将包发布到 npm 上面的时候,意味着我们发布了哪些文件,最终用户下载安装的时候就会得到哪些文件,因此我们在发布的时候就要尽量避免不要上传没有意义的文件,这里就涉及到设置忽略文件。

设置忽略文件的方式有两种:

  • 黑名单
  • 白名单

黑名单

在项目根目录下面创建一个 .npmignore 的文件,该文件就用于设置哪些文件或者目录不需要上传到 npm

bash 复制代码
# .npmignore
src
tests

对于黑名单的方式,在新增了不需要发布的文件后,容易忘记修改 .npmignore 文件,因此我更加推荐使用白名单的方式。

白名单

所谓白名单,就是只只有出现在我名单里面的文件或目录才会被上传。

在 package.json 文件里面有一个 files 字段,只有 files 字段里面出现的文件或目录才会被上传。

json 复制代码
{
  "name": "toolset2",
  "version": "1.0.7",
  "private": false,
  "description": "This is a JavaScript function library, primarily aimed at learning and communication.",
  // ...
  "files": [
    "/dist",
    "LICENSE"
  ]
}

设置模块类型

通过 type 值来设置模块类型。type 对应的有两个值:

  • commonjs :当 type 的值设置为 commonjs 时,node.js 将默认使用 CommonJS 模块系统,这是 node.js 中最常见的模块系统。在这种情况下,可以直接使用 require 函数来导入模块。如果想使用 ECMAScript 模块(即使用 importexport 语法),则需要将文件扩展名设置为 .mjs
  • module :当 type 的值设置为 module 时,node.js 将默认使用 ECMAScript 模块系统。在这种情况下,可以直接使用 importexport 语法来导入和导出模块。如果你想使用 CommonJS 模块(即使用 require 导入模块),则需要将文件扩展名设置为 .cjs

说白了现在的 node,你使用哪一种模块规范都无所谓,因为它两种都支持,你只需要通过 type 值来配置一下就可以了。

关于 type 这个配置项,不是 npm 所提供的配置选项,而是 node 提供的配置选项。

node 常见的配置项除了 type 以外,还有一个 exports,该配置项用于定义一个模块的导出映射,通过这个配置项,可以对模块的导入环境以及条件做一个更精细的控制,指定不同的模块的入口文件。

js 复制代码
{
  "exports": {
    "import": "./dist/index.esm.js",
    "require": "./dist/index.cjs"
  }
}

我们在这里为不同的模块系统提供了不同的入口文件

  • 使用的 ESM,那么在导入模块的时候,node.js 会去加载 index.esm.js
  • 使用的是 commonjs,那么在导入模块的时候,node.js 会去加载 index.cjs

打包发布

首先我们有如下的项目:

js 复制代码
- src
   |-- index.js
	 |-- sum.js
   |-- sub.js
- package.json
- rollup.config.js

package.json 配置如下:

json 复制代码
{
  "name": "dyjstools",
  "version": "1.0.1",
  "description": "this package just for study and useless",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "build": "rollup -c"
  },
  "exports": {
    "require": "./dist/index.cjs",
    "import": "./dist/index.js"
  },
  "keywords": [
    "study",
    "useless"
  ],
  "files": [
    "/dist"
  ],
  "license": "ISC",
  "devDependencies": {
    "rollup": "^2.79.1",
    "rollup-plugin-node-resolve": "^5.2.0",
    "rollup-plugin-terser": "^7.0.2"
  }
}

上面的重点配置:type、exports、files

还有一点一定要注意,rollup 打包工具一定要声明为开发依赖。

还有一些命令:

  • npm whoami:查看当前登录的用户
  • npm publish:发布包(如果你是要向 npm 推送包,确定你镜像切换为了 npm 镜像)

搭建npm私有服务器

在企业应用开发中,很多时候我们要发布的包是私有的,npm上面倒是支持发布私有包,但是需要付费账号,因此更好的选择就是搭建私有服务器。

  • 能够保证代码的私密性
  • 因为是在局域网内部,因此下载速度更快
  • 可以将发布的包做一些权限上设置,利于维护

Verdaccio

Verdaccio 是企业开发中非常流行的用来搭建 npm 私有仓库的一个工具,通过该工具可以让我们快速的搭建一个 npm 私服。

下面是关于 Verdaccio 的一些主要特点:

  • 轻量级:Verdaccio 采用 Node.js 编写,安装和运行起来非常快速。它不依赖于任何外部数据库,而是将数据存储在本地文件系统中。
  • 简单的配置:Verdaccio 的配置非常简单,只需一个 YAML 文件即可。您可以轻松地指定用户权限、上游代理、缓存设置等。
  • 缓存和代理:Verdaccio 可以作为上游 npm 注册表的代理,从而帮助减轻网络延迟和提高包的安装速度。同时,它还会缓存已经下载的包,以便在没有互联网连接的情况下也能正常工作。
  • 访问控制:Verdaccio 支持基于用户和包的访问控制,您可以轻松地管理谁可以访问、发布和安装私有 npm 包。
  • 插件支持:Verdaccio 支持插件,您可以扩展其功能,如添加身份验证提供程序、审计日志等。

首先第一步需要安装:

js 复制代码
npm i -g verdaccio

查看 verdaccio 的基本信息:

bash 复制代码
verdaccio -h

要启动服务器只需要输入

bash 复制代码
verdaccio

Verdaccio 相关的配置

Verdaccio 基本上做到了开箱即用,但是很多时候我们需要根据项目的需求做一些配置,你可以在官网查看到 Verdaccio 所有的配置:verdaccio.org/docs/next/c...

首先要说一下,Verdaccio 配置文件采用的是 YAML 格式,这是配置文件的一种常用格式,基本的语法结构由键值对组成,使用缩进来表示层级关系,键值对使用冒号分隔,键和值之间使用一个空格分隔

yaml 复制代码
person:
  name: John
  age: 30
  address:
    street: Main St.
    city: New York

Verdaccio 相关的一些配置:

  • storage:存储包的路径
  • web:网站相关的配置,录入 titile 一类的
  • uplinks:上游代理,我现在搭建了私服,但是我通过私服下载某些包的时候,私服可能没有。这个时候就会从上游代理中去下载这些包,然后缓存到私服里面
yaml 复制代码
uplinks:
  npmjs:
    url: https://registry.npmjs.org/
  • packages:这个配置项就是对权限的控制,例如:
bash 复制代码
packages:
  '@your-scope/*':
    access: $authenticated
    publish: $authenticated
    proxy: npmjs
  '**':
    access: $all
    publish: $authenticated
    proxy: npmjs

@your-scope/ 这个作用域包,只允许认证过的用户访问和发布,对于其他的包,所有用户都能够访问,但是只有认证过的用户才能发布,从而能够对权限做一个很好的控制。

  • auth:设置用户身份的验证方法,默认采用的是 htpasswd 的方式

镜像管理工具nrm

nrm 是一个专门用于管理 npm 镜像的工具,英语全称就是 npm registry manager

首先我们需要安装 nrm:

bash 复制代码
npm i -g nrm

安装的时候可能会遇到如下的错误:

js 复制代码
const open = require('open');
^

Error [ERR_REQUIRE_ESM]: require() of ES Module /Users/jie/.nvm/versions/node/v16.17.1/lib/node_modules/nrm/node_modules/open/index.js from /Users/jie/.nvm/versions/node/v16.17.1/lib/node_modules/nrm/cli.js not supported.

这是因为 nrm 依赖于一个名为 open 的包,因此你在安装 nrm 的时候,同时也安装 open 即可:

bash 复制代码
npm install -g nrm open@8.4.2

nrm 常见的指令如下:

  • nrm ls:列出你所有的可用的镜像列表
  • nrm use <registry>:切换镜像
  • nrm add <registry> <url>:添加镜像
  • nrm del <registry>:删除镜像

yarn&pnpm

我们来了解一下 yarn 和 pnpm 是在什么背景下出现的,解决了 npm 当时所存在的什么问题。

yarn

yarn这个包管理器是在 2016 年出现的,yarn 是由几家公司(Facebook、google、Exponent)的团队共同开发推出的。

yarn官网:yarnpkg.com/

当时 yarn 的出现,主要是为了解决 npm 在速度、安全性以及一致性上面的一些问题。

  • 安装速度:在早期的时候,npm 的安装速度是很慢的,尤其是在大型项目中尤为明显,yarn 使用了并行下载以及缓存的机制,显著的提升了依赖安装的速度。
  • 一致性:npm 在安装依赖的时候,同一个项目在不同时期进行安装的话,会产生不同的 node_module 结构,因为早期的 npm 只有 package.json,该文件只能确定包的元数据信息(包名、版本、作者....)以及直接依赖,但是间接依赖的版本是没有确定的,这样就会存在因为间接依赖版本不一致导致的项目无法重现的潜在问题。在 yarn 中,引入了一个名为 yarn.lock 的锁文件,该文件记录了所有依赖(直接依赖以及间接依赖)的版本信息,从而保证同一个项目无论什么时候安装的依赖都是相同的。
  • 安全性:yarn 提供了一种在安装过程中对包进行校验的机制,确保包的完整性。
  • 离线安装:在没有网络的情况下,可以从缓存中离线安装依赖。

这里罗列出中yarn中常用指令和npm之间的对比:

npm yarn 说明
npm init yarn init 初始化项目
npm install/link yarn install/link 默认的安装依赖操作
npm install <package> yarn add <package> 安装某个依赖
npm uninstall <pacakge> yarn remove <package> 移除某个依赖
npm install <package> --save-dev yarn add <pacakge> --dev 安装开发依赖
npm update <package> --save yarn upgrade <package> 更新某个依赖
npm install <package> --global yarn global add <pacakge> 全局安装
npm publish/login/logout yarn publish/login/logout 发布/登录/登出
npm run <script> yarn run <script> 执行 script 命令

通过上面的表格对比,我们可以看出,从 npm 切换到 yarn 基本上是无缝切换,没有什么学习成本。

在 yarn 出现之后,npm团队也意识到了这些问题,对后续的 npm 做了一定程度的改进。

  • 速度:从 npm v5 版本开始,内部做了一些优化,包括提供了缓存的机制,因此速度大幅提升
  • 确定性:从 npm v5 版本开始,提供了一个名为 package.lock.json 的文件,该文件就类似于 yarn.lock 文件,会记录所有依赖的版本信息。
  • 安全性:使用 npm audit 这个指令可以检查是否有存在漏洞的包。
  • 离线安装:从 npm v5 开始,也引入缓存的机制,在没有网络的时候,也可以从缓存中进行安装。

pnpm

pnpm 是一个最近新推出的包管理器,它主要是解决了 npm 和 yarn 在包安装策略上面的一些问题。

pnpm官网:pnpm.io/

  1. 磁盘空间利用的问题

在 npm 和 yarn 中,当多个项目使用相同的依赖包的时候,这些依赖包会在每个项目都存在一份。这个对我们的磁盘空间来讲实际上是一种浪费。pnpm就解决了这个问题,在 pnpm 中会使用一个全局的存储空间,存放已经安装的包,在每个项目里面的 node_modules 里面会创建相同的符号链接去链接全局的包,这样相同的包只需要存储一次,从而节省了磁盘空间。

  1. 有更加严格的依赖关系管理

pnpm 有更加严格的依赖关系管理,限制开发者只能在代码中引入 package.json 里面声明了的依赖包。

这是一个比较经典的问题,被称之为幽灵依赖。假设我在 package.json 里面依赖了 A 这个包(直接依赖),A 这个包又间接依赖 B 这个包(间接依赖),根据 npm 的下载规则,会将 A 和 B 这两个包都下载到 node_modules 目录里面,B就被称之为幽灵依赖。B 这个幽灵依赖由于和 A 是平级的,用户就可以直接引入 B 这个包,哪怕在 package.json 里面并没有书写这个依赖项

js 复制代码
import B from "B";

这里会存在以下两个问题:

  • 难以理解的依赖关系:当一个包意外的去引入一个幽灵依赖时,会导致整个依赖关于变得混乱,难以维护和追踪。
  • 潜在的错误:假设我们现在在项目中直接引入了 B 这个幽灵依赖,后面在重现项目的时候,A 的版本更新了引起 B 的版本也更新了,那么在你的项目中就会存在兼容性的问题。

pnpm 会更加严格的控制依赖包,pnpm 在创建 node_modules 的时候,只会存在package.json 中声明了的依赖,其他的间接依赖(幽灵依赖)统统不会在 node_modules 里面出现。

pnpm的基本使用

从 npm 到 pnpm 的切换,基本上也就是零学习成本,可以做到无缝切换:

  • 安装 pnpm :可以使用 npm 或者 yarn 进行安装,npm install -g pnpm
  • 创建新项目:pnpm init
  • 添加依赖:pnpm add <package>
  • 添加所有依赖:pnpm install
  • 升级依赖:pnpm update <package>
  • 删除依赖:pnpm remove <package>

关于 pnpm 还有一个非常重要的知识点,叫做 workspace(工作空间),在后面我们介绍 monorepo 的时候,再来进行介绍。

针对 pnpm的出现,解决了上述所存在的问题,npm 和 yarn 自身也在做一些改进:

  • npm :从 npm v7 开始,引入了一项名为 "Workspaces " 的功能,用于管理多个包的 monorepo 结构。这在一定程度上解决了多个项目之间共享相同依赖包的问题。然而,npm 仍然在每个项目的 node_modules 目录中存储依赖包,所以在磁盘空间利用上没有得到明显改善。

  • yarnyarn v1 提供了 "Workspaces " 功能,用于管理 monorepo 结构。yarn v2Yarn Berry )引入了 "Plug'n'Play "(PnP )安装策略,它摒弃了 node_modules 目录,而是将依赖包存储在一个全局的位置,并直接从这个位置加载依赖。这在一定程度上解决了磁盘空间利用的问题。然而,PnP 改变了 Node.js 的模块解析方式,可能导致兼容性问题,因此并非所有项目都能直接使用。

关于几个包管理器之间的详细功能区别,在 pnpm 官网上面也有一张图:pnpm.io/feature-com...

monorepo&multirepo

无论是monorepo还是multirepo,都是在面对多个项目的时候一套项目代码管理的方案。

mono 是英语里面"单一的、单独的",repo 是"repository"单词的简写,表示"仓库"的意思。

因此 monorepo 指的就是使用单一仓库来管理多个项目。

首先我们需要搞清楚,什么算是一个仓库?

一般来讲,一个仓库是指通过版本控制系统(git)进行管理的代码库。在 git 的上下文中,一个仓库通常是指一个包含 .git 子目录的目录。.git 这个目录通常包含了所有版本的提交历史、分支以及标签等信息。

因此,一个通过 git 初始化后的目录才算是一个仓库。

  • monorepo指的是使用单一仓库管理多个项目
  • multirepo(polyrepo)指的是使用多个仓库管理多个项目,通常就是一个项目对应一个仓库

monorepo 这种多项目的管理方式,乍看之下是反直觉的。因为我们会下意识的认为多个项目就应该对应多个仓库,但是随着公司业务的发展,往往类似的项目会越来越多,一个项目对应一个仓库往往会存在以下的一些弊端:

  • 每个项目都要搭建 eslint + prettier + commitLint + styleLint 等代码规范校验
  • 每个项目都要搭建许多类似的常用组件和创建公共 utils 函数
  • 各个项目的主要依赖库(vueelementPlus)版本可能不一致,且存在重复安装
  • 有时候组件改动会涉及到多个项目都要手动更改

考虑到我们在不同的项目的业务中,往往会使用到公共的组件库以及公共的依赖,那么采用 monorepo 风格来管理多个项目就比较合适了。monorepo 可以让多个模块共享同一个仓库,因此它们可以共享同一套构建流程、代码规范,这些都是可以做到统一的,特别是如果存在公共的组件或者工具函数的情况下,查看代码、修改 bug、调试这些都会变得非常的方便。

目前很多知名的公司都是采用 monorepo 风格来管理公司内部的多个项目:

  • 谷歌(Google ):谷歌是使用 monorepo 的著名代表之一。他们将所有项目和库存储在一个称为 Piper 的庞大代码库中。这使得他们能够更容易地管理依赖关系、共享代码并跨项目进行更改。

  • MetaMeta 也采用了 monorepo 策略。他们的代码库包括了多个项目,如 React、React NativeJest 等。这有助于他们统一管理这些项目的代码和依赖关系。

  • 微软(Microsoft ):虽然微软在某些方面使用了多代码库策略,但他们在一些项目中也采用了 monorepo 。例如,他们的 Windows 操作系统和 Visual Studio 代码编辑器都使用 monorepo 进行管理。

  • TwitterTwitter 也是 monorepo 的支持者,他们将所有代码存储在一个称为 Pants 的代码库中。这有助于他们更好地管理依赖关系和提高代码复用率。

  • UberUber 使用一个名为 "Fusion.js "的 monorepo 代码库来管理他们的许多前端项目。这使他们能够更有效地共享代码和跨项目进行更改。

使用 monorepo 风格来管理多项目,主要的好处就是代码共享,这里还可以罗列出一些其他的好处:

  • 统一的依赖管理:在 monorepo 中,所有项目和库的依赖关系都在同一个地方进行管理。这有助于减少版本冲突和依赖问题。团队可以更容易地保持依赖关系的一致性和更新。

  • 简化代码共享:由于所有项目都在同一个代码库中,开发人员可以轻松地共享代码和资源。这有助于提高代码复用率和降低维护成本。

  • 更容易进行跨项目更改:在 monorepo 中,对多个项目进行协调更改变得更加简单,因为所有项目都在同一个地方。这降低了跨项目更改的复杂性和风险。

  • 更好的跨团队协作:monorepo 有助于提高团队之间的协作效率,因为开发人员可以轻松地查看其他项目的代码和进度。

在企业开发中,如果你选择使用 monorepo 风格来管理你的多个项目,那么通常是将公共的组件、工具库、api 进行一个抽离共享

但是这里要说一下,monorepo 这种风格去管理多个项目,不是说都是优点,也存在一些缺点的。

  • 代码库的规模:随着项目数量的增加,那么我们的代码库就会变得非常的庞大。
  • 缺乏独立版本的控制:所有项目共享相同的提交历史,可能会导致独立版本的控制变得非常复杂。
  • 权限和安全性问题:由于所有项目都是在同一个代码库里面,权限控制会变得比较困难,安全性就会变得弱一些。
  • 工具和基础设施的要求:随着项目数量的增加,代码库肯定是会越来越大的,这个时候就需要一些特定的工具以及基础设施来辅助管理,例如谷歌就用了自定义的代码库以及构建系统来管理它们庞大的monorepo

因此我们在考虑管理多个项目的时候,不是说直接无脑使用 monorepo 风格就行了,需要基于你工作的实际场景以及需求来确定。

一般来讲,以下的情况你可以考虑使用 monorepo:

  1. 当项目之间有很多共享代码和资源时:monorepo 可以使代码共享变得更加简单,有助于提高代码复用率。

  2. 当团队需要频繁进行跨项目协作时:monorepo 有助于提高团队之间的协作效率,尤其是在需要跨项目进行更改的情况下。

  3. 当统一依赖管理对项目至关重要时:如果项目之间的依赖关系管理具有很高的优先级,monorepo 可以提供更好的依赖管理解决方案。

理解了 monorepo 的概念后,multirepo 的概念也就很简单了,就是不同的项目对应独立的仓库。在这种情况下,每个项目和库都会有自己的版本控制历史和依赖关系。

  • 独立版本控制:每个项目和库可以独立跟踪其版本控制历史,使得版本控制更加清晰。

  • 更小的代码库规模:由于每个项目和库都有自己的代码库,因此每个代码库的规模相对较小,便于管理和克隆。

  • 更高的项目自治:每个项目和库都可以根据自己的需求选择技术栈和依赖管理策略,提高项目的灵活性。

当然 multirepo 的缺点就是代码共享会变得困难一些。

下面有一张表对比了两个多项目代码管理风格的区别:

monorepo multirepo
开发 只需要在一个仓库中开发,编码方便,新成员入门简单 仓库体积小,模块划分清晰。
复用 代码复用高,方便进行代码重构 需要多仓库来回切换,无法实现跨项目代码复用
工程配置 所有项目统一使用相同的工程配置 各个项目可能有一套单独标准
依赖管理 共同依赖可以提升到 root,版本控制更加容易,依赖管理会变得更方便 不同的项目会安装相同的依赖,但即便相同的依赖会存在版本不同的情况
代码管理 代码全在一个仓库,项目太大用 git 管理会存在问题,无法隔离项目代码权限 各个团队可以控制代码权限,也几乎不会有项目太大的问题
部署 lerna 工具支持 如果多项目存在依赖关系,开发就需要在不同的仓库按照依赖先后顺序去修改版本以及进行部署

搭建 monorepo 工程

目前在企业里面搭建 monorepo 工程常见的方案有三种:

考虑到 pnpm 内置了对 monorepo 的一个支持,搭建起来非常的简单快捷、门槛较低,所以我们选择采用 pnpm 的方案来搭建我们的工程。

工作区

workspace 翻译成中文,叫做"工作区"。

一说到工作区,大家会想到上面的场景,在现实生活中,一个工作区意味着一个工作的空间,在这个工作的空间里面有你工作时需要的一切东西。

在软件开发中,工作区通常是指一个用于组织和管理项目文件、资源以及工具的逻辑容器。它可以是一个文件夹结构,用于将相关的代码、文件以及配置、其他资源集中的放置到一起。

工作区的主要功能包括:

  • 组织和管理项目文件:工作区提供了一个用于存储和组织项目文件的结构。这种结构通常包含源代码、配置文件、测试文件和其他与项目相关的资源。

  • 跨项目共享设置和工具:工作区允许开发者在多个项目之间共享设置、依赖和工具。这有助于保持项目的一致性,并减少在不同项目之间切换时的开销。

  • 支持协同开发:工作区有助于团队成员协同开发多个项目。团队成员可以在同一个工作区中访问和修改项目文件,从而提高协同开发的效率。

在许多编程语言、框架以及开发工具中,都能看到工作区的概念。pnpm 中同样提供了工作区的功能,用于管理monorepo风格的多个项目。要在 pnpm 中创建一个工作区,非常的简单,只需要创建一个名为 pnpm-workspace.yaml 的文件,然后在该文件中定义哪些目录被包含在工作区即可。下面是一个 pnpm-workspace.yaml 的示例:

yaml 复制代码
packages:
  # packages/ 下所有子包,但是不包括子包下面的包
  - 'packages/*'
  # components/ 下所有的包,包含子包下面的子包
  - 'components/**'
  # 排除 test 目录
  - '!**/test/**'

搭建 monorepo 工程

首先创建一个新的目录:

bash 复制代码
mkdir frontend-projects2

接下来使用 pnpm 对该目录进行一个初始化

bash 复制代码
pnpm init

接下来下一步,我们就需要创建一个工作空间

bash 复制代码
touch pnpm-workspace.yaml

在 pnpm-workspace.yaml 中记入如下的内容:

yaml 复制代码
packages:
  - 'components/*'
  - 'utils/*'
  - 'projects/*'

上面的配置表示 components、utils、projects 这三个目录下面的所有子包会被放入到工作空间里面,在一个工作空间中,就意味着项目之间能够相互引用。

  • components:存放公共组件的
  • utils:存放工具库
  • projects:各个项目

我们就来封装一个公共的函数库,这个函数库我们是可以正常打包,正常发布,以及能够被工程中的其他项目引用的。

首先我们在 utils 下面创建了一个名为 tools 的目录,该目录是我们的公共函数库,使用 pnpm init 进行初始化。

接下来我们会遇到第一个问题,函数库使用 typescript 进行安装,那么 typescript 安装到哪里?

考虑到 typescript除了这个工具库会使用以外,其他的项目大概率也会使用,因此我们选择将 typescript 安装到工作空间里面,命令如下:

bash 复制代码
pnpm add typescript -D -w

最后的 -w 就表示安装到工作空间。

接下来我们进行源码开发,源码对应如下:

ts 复制代码
// src/index.ts
export * from "./sum.js";
export * from "./sub.js";
ts 复制代码
// src/sum.ts
export function sum(a: number, b: number) {
  return a + b;
}
ts 复制代码
// src/sub.ts
export function sub(a: number, b: number) {
  return a - b;
}

源码开发工作结束。

因为我们开发的这个项目是一个公共的函数库,其他的项目也会使用该函数库,所以这个公共的函数库里面的每一个方法都需要进行测试。这里的测试我们选择使用 jest,因此这里就又涉及到一个装包装在哪儿的问题,考虑到其他项目也是需要测试的,因此我们还是将 jest 安装到工作空间里面:

bash 复制代码
pnpm add jest jest-environment-jsdom @types/jest -D -w

装包完毕后,接下来就开始书写测试代码,代码如下:

ts 复制代码
// tests/sum.test.ts
import { sum } from "../src/sum";

test("测试sum方法", () => {
  const result = sum(1, 2);
  expect(result).toBe(3);
});
ts 复制代码
// tests/sub.test.ts
import { sub } from "../src/sub";

test("测试sub方法", () => {
  const result = sub(10, 3);
  expect(result).toBe(7);
});

接下来我们需要创建一个 jest 的配置文件,通过以下命令进行创建:

bash 复制代码
npx jest --init

注意为了能够让 jest 识别 ts 文件,我们还需要安装如下的两个依赖:

bash 复制代码
pnpm add ts-jest ts-node -D -w

另外记得把 jest 的配置文件里面的 preset 设置为 ts-jest

上面的配置文件配置后了,理论上来讲 jest 跑测试这一块就能够跑的通了,但是还会提示你让你创建一个 ts 的配置文件:

bash 复制代码
npx tsc --init

这里我们修改了如下的配置:

  • target:ES6
  • module:ES6
  • include:["./src"]
  • declaration: true
  • declarationDir: "./dist/types",

至此,我们的源码测试也就是做完了。

接下来既然开发和测试都已经完成了,那么就应道到了打包和发布的阶段。

打包我们这里选择使用 rollup 来进行打包,而且打包我们会打包成三种格式:CommonJS、Brower、ES Module

安装如下的依赖:

bash 复制代码
pnpm add rollup rollup-plugin-typescript2 @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-json @rollup/plugin-babel @babel/preset-env -D -w

打包依赖安装好之后,我们就需要书写一份打包的配置文件,在 tools 根目录下创建一个 rollup.config.js 的文件,配置如下:

js 复制代码
import typescript from "rollup-plugin-typescript2";
import commonjs from "@rollup/plugin-commonjs";
import resolve from "@rollup/plugin-node-resolve";
import json from "@rollup/plugin-json";
import babel from "@rollup/plugin-babel";

const extensions = [".js", ".ts"];

export default [
  // CommonJS
  {
    input: "src/index.ts",
    output: {
      file: "dist/index.cjs",
      format: "cjs",
    },
    plugins: [
      typescript({
        useTsconfigDeclarationDir: true,
      }),
      resolve({ extensions }),
      commonjs(),
      json(),
    ],
  },
  // ESM
  {
    input: "src/index.ts",
    output: {
      file: "dist/index.js",
      format: "es",
    },
    plugins: [
      typescript({
        useTsconfigDeclarationDir: true,
      }),
      resolve({ extensions }),
      commonjs(),
      json(),
    ],
  },
  // Browser-compatible
  {
    input: "src/index.ts",
    output: {
      file: "dist/index.browser.js",
      format: "iife",
      name: "jsTools",
    },
    plugins: [
      typescript({
        useTsconfigDeclarationDir: true,
      }),
      resolve({ extensions }),
      commonjs(),
      json(),
      babel({
        exclude: "node_modules/**",
        extensions,
        babelHelpers: "bundled",
        presets: [
          [
            "@babel/preset-env",
            {
              targets: "> 0.25%, not dead",
            },
          ],
        ],
      }),
    ],
  },
];

上面的配置文件写好之后,还有一个非常重要的地方需要修改,那就是 package.json,这个文件是对我们整个包的一个说明文件,它直接决定了别人如何来使用我们的包,重点的配置项目如下:

json 复制代码
{
  "main": "dist/index.cjs",
  "module": "dist/index.js",
  "type": "module",
  "types": "dist/types/index.d.ts",
  "exports" : {
     "require": "./dist/index.cjs",
     "import": "./dist/index.js"
  },
  "script" : {
    "build" : "rollup -c"
  }
}

之后,运行 pnpm build 进行一个打包操作,打包完成后,会在 tools 的根目录下生成一个 dist 目录,里面包含打包好后的文件。

打包完成后,我们就可以将 dist 上传到 npm 或者私服上面。

最后我们再来测试一下 projects 下面的项目能否引入刚才的写好的公共函数库。

在 projects 下面创建一个新项目 tools-test-proj,使用 pnpm init 进行初始化。

接下来涉及到一个问题,我们的 tools-test-proj 这个项目想要使用 tools 里面的工具方法,由于两个项目是在同一个工作空间里面,所以这里可以直接从工作空间里面进行一个安装操作:

bash 复制代码
pnpm add tools -w --filter tools-test-proj

安装完成后,我们就能够在 package.json 中看到这个依赖,并且这个依赖是来自于工作空间的:

json 复制代码
"dependencies": {
  "tools": "workspace:^"
}

之后在 tools-test-proj 目录下创建 src 源码目录,写入如下代码:

ts 复制代码
import {sum, sub} from "tools";

console.log(sum(1, 2));
console.log(sub(10, 3));

对应的 pakcage.json 需要做一些调整:

  • "type": "module"

另外 ts 的配置文件也需要做出一定的调整:

  • "target": "ESNext",
  • "module": "ESNext",
  • "moduleResolution": "node",
  • "outDir": "./dist",
  • "include": ["./src"]

最后配置脚本:

json 复制代码
"scripts": {
   "start" : "tsc && node ./dist/index.js"
},

执行 pnpm start 就能看到该项目成功的引入了 tools 依赖。

封装组件与测试

上面我们搭建了公共的函数库,这一章我们来搭建公共的组件库。

前期准备

这里使用 vue-cli 来搭建项目,注意在搭建项目的时候需要勾选单元测试,因为我们搭建的是公共的组件库,这意味着我们所写的组件会在其他很多项目中被使用,所以需要做单元测试。

拉取好项目之后,我们发现 node_modules 里面的依赖还是采用传统的 npm 的方式,以平铺的形式将所有的直接依赖以及间接依赖全部放到了 node_modules

这里我们打算使用 pnpm 的依赖管理方式,删除原本的 node_modules 以及 package.lock.json,然后通过 pnpm i 重新来安装依赖。

注意,当我们重新使用 pnpm i 安装了依赖之后,serve 运行项目以及 build 还有 lint 都是正常的,唯独测试 test:unit 跑不起来,究其原因是因为 jest-environment-jsdom 这个依赖的版本有问题,因为会使用工作空间里面的 jest-environment-jsdom,而工作空间里面安装的版本是 29.5.0,但是在该项目中需要的是 27.5.1,解决方法也很简单,我们只需要在当前的组件库项目里面安装 27.5.1 版本的 jest-environment-jsdom 这个依赖即可。

bash 复制代码
pnpm add jest-environment-jsdom@27.5.1 -D

封装组件

这里我们还是以 button 为例,首先我们在 components 目录下面新建一个 Button.vue,组件代码如下:

vue 复制代码
<template>
    <button>按钮</button>
</template>

<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
    name : "dybutton"
});
</script>

<style scoped>

</style>

接下来我们在 main.ts 中对该组件进行一个全局的注册,全局注册之后该项目中无论哪一个组件都可以直接使用。

ts 复制代码
// main.ts
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App);

// 引入组件
import Button from "./components/Button.vue";


// 注册组件
// 组件的名字,对应的组件
app.component(Button.name, Button);

app.mount('#app');

这里我们模拟 element-ui 的按钮组件来进行封装,经过分析,该按钮有如下的参数以及事件:

参数支持:

参数名 参数描述 参数类型 默认值
type 按钮类型(primary/success/warning/danger/info string default
plain 是否是朴素按钮 boolean false
round 是否是圆角按钮 boolean false
circle 是否是圆形按钮 boolean false
disabled 是否禁用按钮 boolean false
icon 图标类名 string

事件支持:

事件名 事件描述
click 点击事件

使用插槽:

简单来说,凡是希望组件中内容可以灵活设置的地方,都需要用到 slot 插槽来自定义内容。所以我们使用 slot 来定义按钮上的文本内容:

vue 复制代码
<template>
    <button>
        <slot></slot>
    </button>
</template>

接下来我们针对该按钮添加一定的样式:

scss 复制代码
.dybutton {
    display: inline-block;
    line-height: 1;
    white-space: nowrap;
    cursor: pointer;
    background: #ffffff;
    border: 1px solid #dcdfe6;
    color: #606266;
    appearance: none;
    -webkit-appearance: none;
    text-align: center;
    box-sizing: border-box;
    outline: none;
    margin: 0;
    transition: 0.1s;
    font-weight: 500;
    //禁止元素的文字被选中
    user-select: none;
    -moz-user-select: none;
    -webkit-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    padding: 12px 20px;
    font-size: 14px;
    border-radius: 4px;

    &:hover,
    &:hover {
        color: #409eff;
        border-color: #c6e2ff;
        background-color: #ecf5ff;
    }
}

注意,上面的样式代码我们使用了 scss 来书写,但是项目里面并没有 sass 以及 sass-loader 的依赖,因此需要安装这两个依赖:

bash 复制代码
pnpm i sass sass-loader -D -w

接下来我们来实现 type 属性,主要是对应样式的书写:

scss 复制代码
.dybutton-primary {
    color: #fff;
    background-color: #409eff;
    border-color: #409eff;

    &:hover,
    &:focus {
        background: #66b1ff;
        background-color: #66b1ff;
        color: #fff;
    }
}

.dybutton-success {
    color: #fff;
    background-color: #67c23a;
    border-color: #67c23a;

    &:hover,
    &:focus {
        background: #85ce61;
        background-color: #85ce61;
        color: #fff;
    }
}

.dybutton-info {
    color: #fff;
    background-color: #909399;
    border-color: #909399;

    &:hover,
    &:focus {
        background: #a6a9ad;
        background-color: #a6a9ad;
        color: #fff;
    }
}

.dybutton-warning {
    color: #fff;
    background-color: #e6a23c;
    border-color: #e6a23c;

    &:hover,
    &:focus {
        background: #ebb563;
        background-color: #ebb563;
        color: #fff;
    }
}

.dybutton-danger {
    color: #fff;
    background-color: #f56c6c;
    border-color: #f56c6c;

    &:hover,
    &:focus {
        background: #f78989;
        background-color: #f78989;
        color: #fff;
    }
}

接下来在组件里面会接受一个名为 type 的 props,然后为当前的 button 动态的拼接样式的类名

vue 复制代码
<template>
    <button class="dybutton" :class="[
        `dybutton-${type}`
    ]">
        <slot></slot>
    </button>
</template>

<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
    name: "dybutton",
    props: {
        type: {
            type: String,
            default: "default"
        }
    }
});
</script>

之后外部就可以通过 type 来决定想要的按钮样式:

vue 复制代码
<div class="row">
  <dybutton>默认</dybutton>
  <dybutton type="primary">primary</dybutton>
  <dybutton type="success">success</dybutton>
  <dybutton type="info">info</dybutton>
  <dybutton type="danger">danger</dybutton>
  <dybutton type="warning">wraning</dybutton>
</div>

接下来是 plain 属性,思路和上面是一样的,首先在组件内部接收一个名为 plain 的属性,然后设置到 button 上面

对应的代码如下:

vue 复制代码
<template>
    <button class="dybutton" :class="[
        `dybutton-${type}`,
        {
            'is-plain': plain
        }
    ]">
        <slot></slot>
    </button>
</template>

<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
    name: "dybutton",
    props: {
        type: {
            type: String,
            default: "default"
        },
        plain: {
            type: Boolean,
            default: false
        }
    }
});
</script>

回头外部在使用该组件,如果传递 plain 这个 props,那么最终渲染时候 button 就会挂一个 is-plain 的样式类,之后针对这个样式类书写对应的样式即可

scss 复制代码
.dybutton.is-plain {

    &:hover,
    &:focus {
        background: #fff;
        border-color: #489eff;
        color: #409eff;
    }
}

.dybutton-primary.is-plain {
    color: #409eff;
    background: #ecf5ff;

    &:hover,
    &:focus {
        background: #409eff;
        border-color: #409eff;
        color: #fff;
    }
}

.dybutton-success.is-plain {
    color: #67c23a;
    background: #c2e7b0;

    &:hover,
    &:focus {
        background: #67c23a;
        border-color: #67c23a;
        color: #fff;
    }
}

.dybutton-info.is-plain {
    color: #909399;
    background: #d3d4d6;

    &:hover,
    &:focus {
        background: #909399;
        border-color: #909399;
        color: #fff;
    }
}

.dybutton-warning.is-plain {
    color: #e6a23c;
    background: #f5dab1;

    &:hover,
    &:focus {
        background: #e6a23c;
        border-color: #e6a23c;
        color: #fff;
    }
}

.dybutton-danger.is-plain {
    color: #f56c6c;
    background: #fbc4c4;

    &:hover,
    &:focus {
        background: #f56c6c;
        border-color: #f56c6c;
        color: #fff;
    }
}

后面的 round、circle、disabled 的做法都和 plain 是一样的。

vue 复制代码
<template>
    <button class="dybutton" :class="[
        `dybutton-${type}`,
        {
            'is-plain': plain,
            'is-round': round,
            'is-circle': circle,
            'is-disabled': disabled,
        }
    ]">
        <slot></slot>
    </button>
</template>

<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
    name: "dybutton",
    props: {
        type: {
            type: String,
            default: "default"
        },
        plain: {
            type: Boolean,
            default: false
        },
        round: {
            type: Boolean,
            default: false,
        },
        circle: {
            type: Boolean,
            default: false,
        },
        disabled: {
            type: Boolean,
            default: false,
        },
    }
});
</script>

外部在使用的时候,就可以挂 round、circle 以及 disabled 这些 props,在真实渲染的时候,就会在 button 上面挂上 is-round、is-circle 以及 is-disabled,之后书写对应的样式即可。

唯独 diabled 这一块你需要注意,需要通过在 button 上面设置 disabled 属性才是真正的把按钮禁用掉了。

vue 复制代码
:disabled="disabled"

最后一个就是图标了。

首先引入了 fonts 目录放入到了 assets 目录下面,然后在 main.ts 中引入图标相关的样式:

ts 复制代码
import "./assets/fonts/font.scss";

在组件内部接收 icon 这个 props,然后有一个 i 标签来书写图标:

vue 复制代码
<template>
    <button class="dybutton" :class="[
        `dybutton-${type}`,
        {
            'is-plain': plain,
            'is-round': round,
            'is-circle': circle,
            'is-disabled': disabled,
        }
    ]" :disabled="disabled">
        <i v-if="icon" :class="`dyicon-${icon}`"></i>
        <span v-if="$slots.default">
            <slot></slot>
        </span>
    </button>
</template>

<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
    name: "dybutton",
    props: {
        type: {
            type: String,
            default: "default"
        },
        plain: {
            type: Boolean,
            default: false
        },
        round: {
            type: Boolean,
            default: false,
        },
        circle: {
            type: Boolean,
            default: false,
        },
        disabled: {
            type: Boolean,
            default: false,
        },
        icon: {
            type: String,
            default: ""
        }
    }
});
</script>

关于按钮,最后一个就是点击,这也是按钮必须要有的功能。做法也很简单,就是触发父组件传递过来的 click 事件即可:

js 复制代码
methods : {
  btnClick(){
    // 触发父组件传递过来的 click 事件
    this.$emit("click");
  }
}

至此,我们整个 button 按钮的封装工作就告一段落。

测试组件

接下来我们针对上面我们所封装的 button 组件进行一个测试,测试代码如下:

ts 复制代码
import { mount } from "@vue/test-utils";
import Button from "@/components/Button.vue";

describe("Button.vue", () => {
  it("renders button with default type", () => {
    const wrapper = mount(Button);
    expect(wrapper.classes()).toContain("dybutton");
    expect(wrapper.classes()).toContain("dybutton-default");
  });

  it("renders button with correct type", () => {
    const wrapper = mount(Button, { props: { type: "primary" } });
    expect(wrapper.classes()).toContain("dybutton");
    expect(wrapper.classes()).toContain("dybutton-primary");
  });

  it("renders button with plain style", () => {
    const wrapper = mount(Button, { props: { plain: true } });
    expect(wrapper.classes()).toContain("is-plain");
  });

  it("renders button with round style", () => {
    const wrapper = mount(Button, { props: { round: true } });
    expect(wrapper.classes()).toContain("is-round");
  });

  it("renders button with circle style", () => {
    const wrapper = mount(Button, { props: { circle: true } });
    expect(wrapper.classes()).toContain("is-circle");
  });

  it("renders button with disabled state", () => {
    const wrapper = mount(Button, { props: { disabled: true } });
    expect(wrapper.classes()).toContain("is-disabled");
    expect(wrapper.attributes()).toHaveProperty("disabled");
  });

  it("renders button with icon", () => {
    const wrapper = mount(Button, { props: { icon: "home" } });
    expect(wrapper.find("i").classes()).toContain("dyicon-home");
  });

  it("renders button with slot content", () => {
    const wrapper = mount(Button, { slots: { default: "Click Me" } });
    expect(wrapper.text()).toContain("Click Me");
  });

  it("emits click event when button is clicked", async () => {
    const wrapper = mount(Button);
    await wrapper.trigger("click");
    expect(wrapper.emitted()).toHaveProperty("click");
  });
});

上面的测试代码中, async 异步测试会报一个 tslib 找不到的问题,将 tsconfig 里面的 target 修改为 ESNext 即可。

组件库打包

组件库的打包主要是涉及到了一些配置文件的书写。

vue.config.js

首先我们在 vue-coms 下面创建一个 vue.config.js 文件:

bash 复制代码
touch vue.config.js

该文件主要是补充一些 webpack 的配置。

这里我们需要安装一个依赖:

bash 复制代码
pnpm i copy-webpack-plugin -D -w

copy-webpack-plugin 这个依赖主要是用于做文件的拷贝。

vue.config.js 对应的配置代码如下:

js 复制代码
// 该依赖主要用于 webpack 在进行构建的时候,将一些文件或者目录复制目标目录里面
const CopyWebpackPlugin = require("copy-webpack-plugin");

module.exports = {
  // 扩展 webpack 配置,使 webpages 加入编译
  chainWebpack: (config) => {
    // 添加了一个新的规则,该规则用于处理字体
    // 首先使用 loader 对字体文件进行一个处理,还做了不同大小的不同处理
    // 如果文件大小小于 10000,那么则采用内联 DataURL 的形式
    // 否则的话就输出到 fonts 目录,并且保留原始文件名和扩展名
    config.module
      .rule("fonts")
      .test(/\.(woff2?|eot|ttf|otf)(\?.*)?$/i)
      .use("url-loader")
      .loader("url-loader")
      .options({
        limit: 10000,
        name: "fonts/[name].[hash:8].[ext]",
      })
      .end();
  },
  configureWebpack: {
    // 配置要用到的插件
    plugins: [
      new CopyWebpackPlugin({
        patterns: [
          {
            from: "src/assets/fonts",
            to: "fonts",
          },
        ],
      }),
    ],
  },
};

注意这里需要安装两个依赖,如下:

bash 复制代码
pnpm add url-loader file-loader -D -w

lib.ts

因为我们这个项目,我们需要打包为一个库,因此我们这里单独提供一个打包库的配置文件

ts 复制代码
// 该文件也是一个入口文件
// 该文件是你在打包成一个库的时候的入口文件

import { App, Plugin } from "vue";
import Button from "@/components/Button.vue";

import "./assets/fonts/font.scss";

const components = [Button];

// 在 vue 中,如果你要将代码打包成一个库,那么需要提供一个 install 的方法
// 在 install 里面我们要做的事情就是注册组件
const install = (app: App) => {
  components.forEach((com) => {
    app.component(com.name, com);
  });
};

const vuecoms: Plugin = {
  install,
};

export default vuecoms;

在上面的入口文件中,最重要的就是需要提供一个 install 方法,一般 install 方法内部就是做组件的注册。

之后 package.json 中的 build 就需要做出修改,如下:

json 复制代码
"scripts": {
   "build": "vue-cli-service build --target lib src/lib.ts",
},

--target lib 表示我们需要将项目打包为一个库,src/lib.ts 表示我打包成库的时候的入口文件

tsconfig

这个是 ts 的配置文件,我们在进行打包的时候,需要生成类型说明文件

我们在项目根目录新生成一个名为 tsconfig.declaration.json 的文件,记入如下的配置:

json 复制代码
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "declaration": true, // 要生成类型说明文件
    "declarationDir": "dist/types", // 类型说明文件的地址
    "emitDeclarationOnly": true, // 仅生成类型说明文件,不需要生成 js 文件
    "noEmit": false // 允许编译器输出文件
  },
  // 指定要编译的目录
  "include": [
    "src/components/**/*.ts",
    "src/components/**/*.tsx",
    "src/components/**/*.vue"
  ],
  // 排除的目录
  "exclude": ["node_modules", "tests"]
}

注意这里也是需要安装一个依赖:

bash 复制代码
pnpm add @types/webpack-env -D -w

配置文件书写完毕之后,需要去修改 package.json,如下:

json 复制代码
"scripts": {
   "build": "vue-cli-service build --target lib src/lib.ts && vue-tsc --project tsconfig.declarations.json",
},

注意上面用到了 vue-tsc 这个依赖,需要对该依赖进行一个安装

bash 复制代码
pnpm add vue-tsc -D

generate-index-d-ts.js

该文件主要是用来生成一个 index.d.ts 文件,作为类型文件的入口文件

js 复制代码
// 该文件的主要作用是生成一个类型说明文件的入口文件
// 这里涉及到 node.js 的知识
// 主要就是读取文件和写入文件

const fs = require("fs");
const path = require("path");

const typesDir = path.resolve(__dirname, "dist/types");
const indexDtsPath = path.join(typesDir, "index.d.ts");

// 从 `dist/types` 目录中读取所有 `.d.ts` 文件
fs.readdir(typesDir, (err, files) => {
  if (err) {
    console.error("Error reading types directory:", err);
    process.exit(1);
  }

  const dtsFiles = files.filter(
    (file) => file.endsWith(".d.ts") && file !== "index.d.ts"
  );

  // 为每个 `.d.ts` 文件创建一个导出语句
  const exports = dtsFiles
    .map((file) => `export * from './${file}';`)
    .join("\n");

  // 定义 index.d.ts 文件内容
  const indexDtsContent = `import { Plugin } from "vue";

declare const vuecoms: Plugin;
export default vuecoms;

${exports}
`;

  // 将内容写入 `index.d.ts` 文件
  fs.writeFile(indexDtsPath, indexDtsContent, (err) => {
    if (err) {
      console.error("Error writing index.d.ts:", err);
      process.exit(1);
    }

    console.log("index.d.ts generated successfully.");
  });
});

对应的 package.json 的 script 也需要做出修改:

json 复制代码
script : {
  "build": "vue-cli-service build --target lib src/lib.ts && vue-tsc --project tsconfig.declarations.json && node generate-index-d-ts.js",
}

package.json

package.json 文件需要做一些配置,主要就是针对入口文件的配置,这样别人在使用你的包的时候,才能知道哪一个是入口文件。

json 复制代码
"main": "dist/vue-coms.common.js",
"module": "dist/vue-coms.umd.js",
"types": "dist/types/index.d.ts",

在其他项目里面引入

首先通过 vue-cli 搭建一个新的项目。

接下来将我们的组件库 vue-coms 添加到新项目里面:

bash 复制代码
pnpm add vue-coms -w --filter vuecoms-test-proj

注意 vue-cli 拉取下来的项目最好通过 pnpm 重新安装一遍依赖,节省磁盘空间

添加了 vue-coms 依赖之后,在 main.ts 中引入即可:

ts 复制代码
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import vuecoms from "vue-coms";

import 'vue-coms/dist/vue-coms.css';
import 'vue-coms/dist/fonts/font.scss';

createApp(App).use(store).use(router).use(vuecoms).mount('#app')
相关推荐
在肯德基吃麻辣烫42 分钟前
使用开源在线聊天工具Fiora轻松搭建个性化聊天平台在线交流
开源
是小崔啊1 小时前
开源轮子 - EasyExcel01(核心api)
java·开发语言·开源·excel·阿里巴巴
快乐非自愿2 小时前
分布式系统架构2:服务发现
架构·服务发现
2401_854391083 小时前
SSM 架构中 JAVA 网络直播带货查询系统设计与 JSP 有效实现方法
java·开发语言·架构
264玫瑰资源库3 小时前
从零开始C++棋牌游戏开发之第二篇:初识 C++ 游戏开发的基本架构
开发语言·c++·架构
神一样的老师3 小时前
面向高精度网络的时间同步安全管理架构
网络·安全·架构
2401_857026233 小时前
基于 SSM 架构的 JAVA 网络直播带货查询系统设计与 JSP 实践成果
java·开发语言·架构
9527华安3 小时前
FPGA实现MIPI转FPD-Link车载同轴视频传输方案,基于IMX327+FPD953架构,提供工程源码和技术支持
fpga开发·架构·mipi·imx327·fpd-link·fpd953
DT辰白3 小时前
如何解决基于 Redis 的网关鉴权导致的 RESTful API 拦截问题?
后端·微服务·架构
川石教育3 小时前
Vue前端开发-缓存优化
前端·javascript·vue.js·缓存·前端框架·vue·数据缓存