0
点赞
收藏
分享

微信扫一扫

拿来吧您!把“前端依赖”纳入知识体系?

📖阅读本文,你将:

  1. 厘清关于依赖的各种思路、细节。
  2. 弄明白通过​​webpack/rollup/vite​​ 等工具构建组件时,是如何处理第三方依赖的。
  3. 思考、弄清楚​​web​​ 应用和组件开发关于依赖处理的差异。
  4. 体系化地思考。

前言

复杂的问题简单化、简单的问题体系化。

一、先想明白:依赖是什么?

先说结论:

有时候,依赖是一堆 可执行的代码 ;有时候,依赖只是 一句声明

怎么理解以上这句话呢?

我们分别解释。

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 疑惑:如何取舍?

看到上面的两种依赖形式,你可能已经有点方了: 为啥有时候依赖是代码,有时候又是声明?我要怎么选择?

不卖关子,我简单捋了几条简单的原则:

  1. 开发​​web​​ 应用时,大部分情况下,你的依赖是“一堆代码”
  2. 如果你的​​web​​​ 应用使用​​CDN​​ 单独引入了一些代码,那这部分你写代码时依赖的是声明
  3. 开发组件时,你依赖的大部分依赖是声明,但如果你希望这些代码成为组件不可分割的一部分,那你需要将它们变成代码

绕迷糊没? 反正我是迷糊了。

拿来吧您!把“前端依赖”纳入知识体系?_前端

阅读完本文,就能捋清各种这让人困惑的问题。

二、​​npm install​​ 的依赖机制

前端引入依赖最常用的方式是 ​​npm install​​。

那么,​​npm install​​ 时究竟是如何运作的呢?它有哪些 特别关键的细节

2.1 依赖从哪儿来?

我们应该从哪里获取依赖?

我认为简单归类的话,应该主要分为几类:

  1. 从​​npm​​ 源安装
  2. 仓库级引用
  3. 从​​CDN​​ 加载
  4. 类​​CDN​​ 方式
  • 2.1.1 从​​npm​​ 源安装
  • 这里的 “源” 是泛指,并不仅限于 ​​npm registry​​,而是泛指那些能通过 ​​npm install​​ 行为被下载的代码。
    应该包含以下几类:
  • 拿来吧您!把“前端依赖”纳入知识体系?_JavaScript_02

  • 公网​​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​​ 和其他一些类似的方式,你可以在仓库内创建其他仓库的软连接,从而达到仓库套仓库的效果。
    比如:
  • 拿来吧您!把“前端依赖”纳入知识体系?_版本号_03

  • 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的版本控制器🧲》​​

简单来说:

拿来吧您!把“前端依赖”纳入知识体系?_Vue.js_04

​semver​​​ 版本号标准通常由三个数字组成,如 ​​16.7.1​​​,但可以通过增加类似 ​​-alpha.1​​ 这样的后缀来形成 先行版

具体的 版本号标准版本模糊匹配、​dist-tag机制 还请移步到上面的文章中具体学习,本文不进行赘述。

学习 版本号标准 的意义在于:

  • 它能帮助你理解,​​npm install​​ 时需要安装哪个版本的包,以及为什么是这样。
  • 当你试图写一个​​web应用​​​ 或​​npm包​​ 时,能准确分析出自己应该如何合理地声明依赖。

2.3 哪些依赖要装、哪些不装?

你能一口气说清楚项目里 ​​node_modules​​ 里的那些依赖都是怎么来的吗?为什么下载了它们,以及为什么只下载了它们?

其实,这只和你项目的 ​​package.json​​​ 里两个重要的属性相关:​​dependencies​​​ 以及 ​​devDependencies​​。

关于这两个属性,大部分人只能说出 “dependencies是生产要用的依赖,devDependencies是开发期用的” 。从语义上来说,这是对的,但从代码执行上来讲,这并不完全正确。

假设一个最简单的场景:

拿来吧您!把“前端依赖”纳入知识体系?_jquery_05

你正在开发 项目A,你 ​​dependencies​​ 了 B库,并 ​​devDependencies​​ 了 C库。同时 ​​B​​​ 和 ​​C​​​ 也都有自己的 ​​dependencies​​​ 及 ​​devDependencies​​。

请问:当你执行 ​​npm install​​ 时,图中的哪些库会被安装?

答案是:​​B​​​、​​C​​​、​​D​​​、​​F​​。

拿来吧您!把“前端依赖”纳入知识体系?_jquery_06

简单来说,在整个依赖树中,只有第一层的 ​​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 的诉求之前,我们依然无法完全放弃那些曾让我们头皮发麻的历史包袱。

拿来吧您!把“前端依赖”纳入知识体系?_Vue.js_07

而在兼容模块化这条路上,​Web 应用前端组件 却有着两套并不相同的处理方案。

因此,当我们在代码里写下 ​​import​​​ 和 ​​require​​ 时,我们需要认识到:

在浏览器中,它们不是被 ​​import​​​ 的,有人替我们抗下了来自降的伤害,比如 ​​webpack​​。

五、 ​​web​​​ 应用:​​webpack​​ 如何把依赖打包?

​webpack​​ 是一个打包器。

它是如何让浏览器支持 模块化 的呢?

当你在 ​​webpack​​​ 项目里写下 ​​import * from '某个依赖'​​​ 时,​​webpack​​ 所需要面对的依然是两个场景:

  • 一堆代码
  • 一个声明

5.1 ​​webpack​​ 如何处理 一堆代码

第一节介绍过,一堆代码 的意思就是:​​import​​ 的内容会被打包到制品中。

这也是最为常见的一种方式。

它会把所有的依赖视作 模块( ​​module​​​ ),然后把多个 ​​module​​ 组合成一个 ​chunk​​。

当页面加载时,会将 ​​chunk​​​ 解开,利用 ​​moduleId​​​ 作为 ​​key​​​,将所有的 ​​module​​​ 存储到 ​​modules​​ 中,大概如下:

拿来吧您!把“前端依赖”纳入知识体系?_Vue.js_08

当然,这中间还包含一些 共同模块已安装过的模块 等简化,不做赘述。

当实际当你的代码执行 ​​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​​ 中获取依赖。

拿来吧您!把“前端依赖”纳入知识体系?_版本号_09

六、 ​​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​​ 在制品格式上有啥区别?都是怎么处理依赖的?
  • 开发组件时应该如何正确处理依赖?

九、关于我

复杂的问题简单化、简单的问题体系化。

我是​​春哥​​​。
大龄前端打工仔,依然在努力学习。
我的目标是给大家分享最实用、最有用的知识点,希望大家都可以早早下班,并可以飞速完成工作,淡定摸鱼🐟。

你可以在公众号里找到我:​​前端要摸鱼​​。


举报

相关推荐

0 条评论