📖阅读本文,你将:
- 厘清关于依赖的各种思路、细节。
- 弄明白通过
webpack/rollup/vite
等工具构建组件时,是如何处理第三方依赖的。 - 思考、弄清楚
web
应用和组件开发关于依赖处理的差异。 - 体系化地思考。
前言
复杂的问题简单化、简单的问题体系化。
一、先想明白:依赖是什么?
先说结论:
有时候,依赖是一堆 可执行的代码 ;有时候,依赖只是 一句声明。
怎么理解以上这句话呢?
我们分别解释。
1.1 它可以是 一堆代码
前端也好、后端也罢,开发的最终目的永远是实现功能,让代码成功地操作机器执行相关的任务。
想象一下,你要使用 vue.js
开发一个 摸鱼交友网站 ,但你不用自己实现一遍 vue.js
的核心逻辑,只需要依赖它、引入它。
像这样:
import vue from 'vue'
或者这样:
<script src="https://unpkg.com/vue@next"></script>
然后,你就可以开始专心撰写业务逻辑。当用户访问你的网站时,他们的浏览器里实际上已经开始运行起了 vue.js
的代码。
通过依赖 vue.js
,我们成功地获得了 一堆代码 。
依赖就是获取一堆可用的代码。
这很好理解。
1.2 它可以是 一个声明
现在,我们假象另外一个场景。
你正在开发一个基于 vue.js
的组件库,因此,你不可避免的会用到 vue.js
的 api
。
例如:
import { ref } from 'vue'
因此,你可以认为自己你开发的组件是 “依赖 vue
” 的,但思考一下,vue
应该被打包到你封装的组件内吗?
emmm…… 当然不应该!
如果 vue.js
被打包到组件代码里,那势必导致各种问题,例如 “实例不一致” 、 “版本难统一” 、 “包体积臃肿” 等诸多问题。
因此,在这种场景下,组件所使用的 vue.js
,实际上是宿主环境所依赖的 vue
。(至于具体怎么依赖,本文的第三节会细说。)
在这种情况下,依赖仅仅是一种声明 ,它并不会实际引入哪怕一行代码。
1.3 疑惑:如何取舍?
看到上面的两种依赖形式,你可能已经有点方了: 为啥有时候依赖是代码,有时候又是声明?我要怎么选择?
不卖关子,我简单捋了几条简单的原则:
- 开发
web
应用时,大部分情况下,你的依赖是 “一堆代码”。 - 如果你的
web
应用使用CDN
单独引入了一些代码,那这部分你写代码时依赖的是 声明。 - 开发组件时,你依赖的大部分依赖是 声明,但如果你希望这些代码成为组件不可分割的一部分,那你需要将它们变成 代码。
绕迷糊没? 反正我是迷糊了。
阅读完本文,就能捋清各种这让人困惑的问题。
二、npm install
的依赖机制
前端引入依赖最常用的方式是 npm install
。
那么,npm install
时究竟是如何运作的呢?它有哪些 特别关键的细节?
2.1 依赖从哪儿来?
我们应该从哪里获取依赖?
我认为简单归类的话,应该主要分为几类:
- 从
npm
源安装 - 仓库级引用
- 从
CDN
加载 - 类
CDN
方式
-
2.1.1 从
npm
源安装
这里的 “源” 是泛指,并不仅限于npm registry
,而是泛指那些能通过npm install
行为被下载的代码。应该包含以下几类:
-
公网
npm registry
执行npm install
时,npm
的默认行为是通过版本号,向https://registry.npmjs.org/
问询版本、下载版本。
但因为众所知周的原因,我们有时候需要切换到taobao
源 等国内源进行加速,通过nrm
、npm config
、.npmrc
、--registry=xxx
等各种手段,都可以轻松完成切源操作。 -
私有
npm registry
并不是所有的代码都适合发布到公网上,因此很多企业选择了自行搭建npm
源,其本质和 “公网npm
源” 并无差别。
但这其中存在一些技巧,比如通过.npmrc
里的相关配置,可以选择性让 某些命名空间的库 从指定源下载。registry = https://registry.npm.taobao.org/ @chunge:registry = https://registry.npm.chunge.cn/
这样一来,就能 公网的归公网、私有的归私有 了。
-
指定
git
仓库
除了从npm registry
下载代码,npm
还支持多种协议,比如:{ "name": "foo", "version": "0.0.0", "dependencies": { "express": "git+ssh://git@github.com:npm/cli.git#v1.0.27" } }
通过指定 协议、仓库地址 以及 tag/commit-hash 等信息,可以精准指定到某个版本的代码。
文档参考:《npm docs》
-
post-install
玩法
从命名上能够看出,post-install
的意思是指install
之后之后会主动触发的一个钩子。
通过在这个钩子内执行脚本,你可以去下载任何你想要的内容,包括但不限于:.exe
、.avi
、.pdf
等等...
-
-
2.1.2 仓库级引用
通过git submodule
和其他一些类似的方式,你可以在仓库内创建其他仓库的软连接,从而达到 仓库套仓库 的效果。
比如: -
2.1.3
CDN
引入
所谓cdn
引入,其实就是通过html
标签,直接向某个资源请求数据。通常情况下这个资源是跨域的、且会动态均衡加速的。
通过cdn
在index.html
的标签内引入资源,有诸多好处:- 多域复用
- 就近传输
- 通过跨域达到 突破浏览器并发限制 的效果
...
在国内
to C
项目中,这是常见的玩法。但贸然引入公共免费CDN
可能需要谨慎评估政策风险,比如jsdeliver
的域名就经常被污染,一旦CDN
陷落,你的网站可能就挂了。
年终奖一命呜呼。 -
2.1.4 类
CDN
玩法
这种就更简单了,把需要的xxx.min.js
文件copy
到静态目录中,跟着制品一起打包,然后通过cdn
类似的方式,在html
中引入文件。
这样当然就无法达到 多域复用、就近传输、突破并发 等效果了。
但胜在稳定,而且对于to B
、to G
那种需要网络隔离的项目,更具优势。
2.2 版本号标准:semver
版本是依赖的核心之一,没有明确的版本号规范,依赖将变得毫无意义。
因此,你是否能回答以下几个问题吗?
^1.1.0
和~1.1.0
的区别是什么?a.b.c
是否合法?1.0.1
、1.0.1-alpha.2
、1.0.1-rc.2
哪个版本号更大?@latest
应该命中谁?@v2-beta
呢?
这块细节过多,我近日单独写了一篇文章来介绍它:
《【一库】semver:语义版本号标准 + npm的版本控制器🧲》
简单来说:
semver
版本号标准通常由三个数字组成,如 16.7.1
,但可以通过增加类似 -alpha.1
这样的后缀来形成 先行版 。
具体的 版本号标准、 版本模糊匹配、dist-tag
机制 还请移步到上面的文章中具体学习,本文不进行赘述。
学习 版本号标准 的意义在于:
-
它能帮助你理解,
npm install
时需要安装哪个版本的包,以及为什么是这样。 -
当你试图写一个
web应用
或npm包
时,能准确分析出自己应该如何合理地声明依赖。
2.3 哪些依赖要装、哪些不装?
你能一口气说清楚项目里
node_modules
里的那些依赖都是怎么来的吗?为什么下载了它们,以及为什么只下载了它们?
其实,这只和你项目的 package.json
里两个重要的属性相关:dependencies
以及 devDependencies
。
关于这两个属性,大部分人只能说出 “dependencies是生产要用的依赖,devDependencies是开发期用的” 。从语义上来说,这是对的,但从代码执行上来讲,这并不完全正确。
假设一个最简单的场景:
你正在开发 项目A,你
dependencies
了 B库,并devDependencies
了 C库。同时B
和C
也都有自己的dependencies
及devDependencies
。
请问:当你执行 npm install
时,图中的哪些库会被安装?
答案是:B
、C
、D
、F
。
简单来说,在整个依赖树中,只有第一层的 devDependencies
是会被安装的。
而从第二层开始的所有 devDependencies
都是不会被安装的。
也就是说 G库、E库
以及它们所有依赖的库类都会在依赖分析时被剔除掉。
这个知识点,我也写过一篇文章阐述:【白话前端】在爱情故事中明悟dependencies和devDependencies的区别
知识点核心参考文档:npm doc
之所以说 “dependencies是生产要用的依赖,devDependencies是开发期用的” 这句话不全对的原因,就在于你其实是可以依赖 devDependencies
的,可惜的是,这并不安全。
2.4 装在哪儿?
npm install
时,那些原本存储在 npm registry
源中的资源,被下载下来之后,都安装在什么位置呢?
node_modules ? 当然!但并不准确。
我们先定义一种语法 A{B,C}
代表 A
包依赖了 B
包和 C
包。
接下来,我们会详细分析 npm install
时安装文件的全逻辑。
假设:存在依赖关系
A{B,C}, B{C}, C{D}
,当在A
包下执行npm install
时:
安装后
A
`-- node_modules
+-- B
+-- C
+-- D
之所以这样,是因为 C
和 B
都会被默认安装 @latest
版本,因而版本一致,只用在 node_modules
根目录下铺平安装即可。
但总会出现版本不一致的情况,比如:
假设:存在一连关系:
A{B,C}, B{C,D@1}, C{D@2}
安装后,目录则为:
A
`-- node_modules
+-- B
+-- C
`-- node_modules
+-- D@2
+-- D@1
依赖分析时,首先将 B
依赖的 D@1
安装到了 node_modules
根目录下,然后发现 C
依赖了 D@2
,此时,就无法在 node_modules
根目录下安装两个 D
了,因此,D@2
被安装在了 node_modules/C/node_modules
文件夹下。
以上步骤标准参考文档:npm docs
在这个安装机制下, 模糊版本匹配的正确使用 对安装效率、依赖体积的帮助是巨大的。
三、NodeJS
应用是如何使用依赖的?
依赖下载下来了,下一步是使用它们。
最简单的场景,是你写一个 Node.js
的应用,比如脚本,这种情况下你不用操心 打包 和 浏览器,你只需要写下如下代码:
const lodash = require('lodash');
当你运行脚本时,lodash
就成功作为你的 依赖 被引入了。
当然,如果你想使用 Esm
,.mjs
格式的文件也是个不错的选择。
在大多数的 NodeJS
应用中,依赖是一种声明,按照本文 第二节 的描述,被声明在 dependencies
里的依然就会被安装,因此无需担心作为组件被使用时,无法获取依赖。
四、Web
应用的依赖为什么更复杂?
为了模块化,前端费劲巴拉
鬼知道刀耕火种的年代,前端先贤们都经历了什么。
- 使用对象作为宿主存储变量、避免全局污染;
IIFE
自制性函数AMD
和requirejs
Umd
!Esm
!
这部分想了解细节?我正好有一篇文章阐述了相关知识:《说不清rollup能输出哪6种格式😥差点被鄙视》
如果你已经了解了上述内容,那一定知道,在 Esm
规范实装之前,浏览器上根本就没有 “模块化” 的概念,js
脚本被加载到页面上,按时序执行,全局变量互相污染。
虽然前端人依靠劳动者的智慧发明了 IIFE
、AMD
、UMD
等模块化解决方案,但确实是无奈之举。
即便在部分现代浏览器已经 支持ESM、Dynamic import 的前提下,我们依然不得不为了兼容那些更早期的浏览器,比如 Chrome 60-
,比如死而不僵的 IE 11
。
在现代浏览器完全占领浏览器市场之前,在甲方不再提 兼容IE 的诉求之前,我们依然无法完全放弃那些曾让我们头皮发麻的历史包袱。
而在兼容模块化这条路上,Web
应用 和 前端组件 却有着两套并不相同的处理方案。
因此,当我们在代码里写下 import
和 require
时,我们需要认识到:
在浏览器中,它们不是被
import
的,有人替我们抗下了来自降的伤害,比如webpack
。
五、 web
应用:webpack
如何把依赖打包?
webpack
是一个打包器。
它是如何让浏览器支持 模块化 的呢?
当你在 webpack
项目里写下 import * from '某个依赖'
时,webpack
所需要面对的依然是两个场景:
- 一堆代码
- 一个声明
5.1 webpack
如何处理 一堆代码
第一节介绍过,一堆代码 的意思就是:
import
的内容会被打包到制品中。
这也是最为常见的一种方式。
它会把所有的依赖视作 模块( module
),然后把多个 module
组合成一个 块chunk
。
当页面加载时,会将 chunk
解开,利用 moduleId
作为 key
,将所有的 module
存储到 modules
中,大概如下:
当然,这中间还包含一些 共同模块、已安装过的模块 等简化,不做赘述。
当实际当你的代码执行 import
时,它们实际已经不再是 import
了,而是被转换过的 __webpack_require__
方法,通过这个方法就能达到 模块化的效果,从 modules
里取到所需要的依赖。
正因为如此,webpack
的构建结果通常显得较为冗余,也是常常被人所诟病的点。不过与相比于它提供的价值,这几乎算是吹毛求疵。
5.2 webpack
如何处理 一个声明
但并不是所有情况下我们都需要把 依赖构建到制品 中。
最典型的场景,便是利用 CDN
加速页面的加载效率。如 UI
库 Element Plus
,它就推荐了 CDN
加载方式:
<head>
<!-- 导入样式 -->
<link rel="stylesheet" href="//unpkg.com/element-plus/dist/index.css" />
<!-- 导入 Vue 3 -->
<script src="//unpkg.com/vue@next"></script>
<!-- 导入组件库 -->
<script src="//unpkg.com/element-plus"></script>
</head>
那么,导入的 CND
应该如何和 webpack
构建配合使用呢?
答案是: externals
。
参考文档:《webpack 中文官网文档 externals》
此属性的作用是:指定某些包不打包到制品中,而是在运行时从外部获取。
而获取的方式,就是 Umd
那套,当 CDN
被加载后,会将其 name
挂载到 window
上,而 webpack
也正是通过这个在全局上获取依赖。
比如:
module.exports = {
//...
externals: {
jquery: 'jQuery',
},
};
你在代码中写的 import * from jquery
,并不会让 node_modules/jquery
被打包到制品中,而是在浏览器加载后的运行时,从 window.jQuery
中获取依赖。
六、 vite
/rollup
怎么把依赖打包?
vite
构建的核心工具是 rollup.js
。
同样的,rollup
也是个打包器。它也不得不面对 webpack
面对的那两个问题:
- 怎么打包 一堆代码
- 怎么打包 一个声明
6.1 vite/rollup
怎么处理 一堆代码?
相比于 webpack
所设计的复杂的 chunk
、module
等加载体系,rollup
显然纯粹的多。
它默认只提供了 6种文件输出结构。
对此,我的这篇文章有细致描述:《说不清rollup能输出哪6种格式😥差点被鄙视》 。
而 vite
在这一点上显然更加激进,它最低以 es2015
作为自己的兼容标准,也就是构建输出的乃是 ESM
模块。参考:vite 官网的描述
因而,在兼容性上,vite
比 webpack
要弱,带来的好处也是显而易见的:
- 制品结构清晰、不冗余、体积小。
- 好理解(
ESM
),不用去学webpackJsonp
是啥了。
当然,官方也给出了更低版本浏览器兼容的法门,按需要使用吧。
6.2 vite/rollup
怎么处理 一个声明?
vite
没有直接提供类似加载 CDN
依赖的配置。但社区提供了类似的插件,比如: vite-plugin-cdn-import
。
而 rollup
,如果你的目标构建格式是 umd
,那么它的 globals
配置,正是用来处理这个问题的。(下一节会细说)
七、从组件开发思考:如何更好地被依赖
上面两节我们从 webpack
、vite
大致了解了 web
应用构建过程中对依赖的处理。
那么,当我们开发组件时,要怎么做才能更好地扮演自己作为 被依赖者 的角色呢?
7.1 组件应该输出什么格式?
大多数情况下:ESM
、UMD
就够了,如果你的组件需要在 node.js
环境运行,那可能还需要加上 CommonJS
格式。
按照本文之前的说法,两种格式分别应对两种场景:
ESM
: 作为 一堆代码 被引用。UMD
: 作为 一个声明 的实际支撑,被用作CDN
引入页面。
因为 rollup.js/vite
默认支持输出 ESM
、UMD
,所以 rollup/vite
实在是开发组件的利器。
值得一提的是,rollup.js
默认行为会把所有的模块打包到一个 js
文件里,这行为显然不符合当下 按需加载 的思路。
因此,通过 preserveModules: true
配置选项,可以让 rollup
只编译,不打包,输出完美的散装 esm
文件格式。
7.2 在组件内如何 只声明、不打包 ?
这是组件开发者永远无法绕开的问题,因为你必须想清楚。
你开发 Element Plus
,不可能内置一套 vue3
吧?按照本文【第二节】的描述,作为一个组件,你应该正确地 声明自己的依赖
-
输出
ESM
:通过rollup external
配置和package.json dependencies
rollup
的external
,和webpack
的externals
的作用类似,但存在差异。rollup external
的作用是:指定部分依赖不打包到制品中,但是在代码中保留import xxx from 'bar'
这样的语句。为了配合这个语法,我们应该把实际依赖声明到
package.json
的dependencies
中。这样当其他应用依赖组件时,会按照【本文第二节】的内容进行安装,并从
node_modules
中去寻找依赖。 -
输出
UMD
:通过rollup output.globals
配置。rollup
的output.globals
,和webpack
的externals
是真的像!// 这是webpack的externals module.exports = { //... externals: { jquery: 'jQuery', }, }; // 这是rollup的globals export default { output: { globals: { jquery: 'jQuery' } } };
不仅写法像,它们的作用也像:
- 被声明
globals
的库,不会被打包到制品中。 - 被引用时会去浏览器的 window 上通过别名寻找。
- 被声明
通过以上两个思路,可以成功解决组件内 只声明、不打包 的需求。
八、总结
通过上面的总结,我们对 前端依赖 有了一个 较为体系的认识 。不妨试试回答这几个问题:
- 什么是依赖?是一堆代码,还是一个声明?
semver
是什么?npm install
时,是怎么处理不同版本号的?都安装在哪?dependencies
和devDependencies
在表现上有什么本质区别?- 为什么
web
端的依赖更加复杂? webpack
和vite
在制品格式上有啥区别?都是怎么处理依赖的?- 开发组件时应该如何正确处理依赖?
九、关于我
复杂的问题简单化、简单的问题体系化。
我是春哥
。
大龄前端打工仔,依然在努力学习。
我的目标是给大家分享最实用、最有用的知识点,希望大家都可以早早下班,并可以飞速完成工作,淡定摸鱼🐟。
你可以在公众号里找到我:前端要摸鱼
。
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。