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

5,260 阅读16分钟

📖阅读本文,你将:

  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.jsapi

例如:

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 行为被下载的代码。

    应该包含以下几类:

    • 公网 npm registry
      执行 npm install 时,npm 的默认行为是通过版本号,向 https://registry.npmjs.org/ 问询版本、下载版本。
      但因为众所知周的原因,我们有时候需要切换到 taobao 源 等国内源进行加速,通过 nrmnpm 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 标签,直接向某个资源请求数据。通常情况下这个资源是跨域的、且会动态均衡加速的。
    通过 cdnindex.html 的标签内引入资源,有诸多好处:

    • 多域复用
    • 就近传输
    • 通过跨域达到 突破浏览器并发限制 的效果
      ...

    在国内 to C 项目中,这是常见的玩法。但贸然引入公共免费 CDN 可能需要谨慎评估政策风险,比如 jsdeliver 的域名就经常被污染,一旦 CDN 陷落,你的网站可能就挂了。
    年终奖一命呜呼。

  • 2.1.4 类 CDN 玩法
    这种就更简单了,把需要的 xxx.min.js 文件 copy 到静态目录中,跟着制品一起打包,然后通过 cdn 类似的方式,在 html 中引入文件。
    这样当然就无法达到 多域复用就近传输突破并发 等效果了。
    但胜在稳定,而且对于 to Bto G 那种需要网络隔离的项目,更具优势。

2.2 版本号标准:semver

版本是依赖的核心之一,没有明确的版本号规范,依赖将变得毫无意义。

因此,你是否能回答以下几个问题吗?

  • ^1.1.0~1.1.0 的区别是什么?
  • a.b.c 是否合法?
  • 1.0.11.0.1-alpha.21.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,你 dependenciesB库,并 devDependenciesC库。同时 BC 也都有自己的 dependenciesdevDependencies

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

答案是:BCDF

简单来说,在整个依赖树中,只有第一层的 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

之所以这样,是因为 CB 都会被默认安装 @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 自制性函数
  • AMDrequirejs
  • Umd!
  • Esm!

这部分想了解细节?我正好有一篇文章阐述了相关知识:《说不清rollup能输出哪6种格式😥差点被鄙视》

如果你已经了解了上述内容,那一定知道,在 Esm 规范实装之前,浏览器上根本就没有 “模块化” 的概念,js 脚本被加载到页面上,按时序执行,全局变量互相污染。

虽然前端人依靠劳动者的智慧发明了 IIFEAMDUMD 等模块化解决方案,但确实是无奈之举。

即便在部分现代浏览器已经 支持ESM、Dynamic import 的前提下,我们依然不得不为了兼容那些更早期的浏览器,比如 Chrome 60-,比如死而不僵的 IE 11

在现代浏览器完全占领浏览器市场之前,在甲方不再提 兼容IE 的诉求之前,我们依然无法完全放弃那些曾让我们头皮发麻的历史包袱。

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

因此,当我们在代码里写下 importrequire 时,我们需要认识到:

在浏览器中,它们不是被 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 加速页面的加载效率。如 UIElement 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 所设计的复杂的 chunkmodule 等加载体系,rollup 显然纯粹的多。

它默认只提供了 6种文件输出结构

对此,我的这篇文章有细致描述:《说不清rollup能输出哪6种格式😥差点被鄙视》

vite 在这一点上显然更加激进,它最低以 es2015 作为自己的兼容标准,也就是构建输出的乃是 ESM 模块。参考:vite 官网的描述

因而,在兼容性上,vitewebpack 要弱,带来的好处也是显而易见的:

  • 制品结构清晰、不冗余、体积小。
  • 好理解(ESM),不用去学 webpackJsonp 是啥了。

当然,官方也给出了更低版本浏览器兼容的法门,按需要使用吧。

6.2 vite/rollup 怎么处理 一个声明

vite 没有直接提供类似加载 CDN 依赖的配置。但社区提供了类似的插件,比如: vite-plugin-cdn-import

rollup,如果你的目标构建格式是 umd,那么它的 globals 配置,正是用来处理这个问题的。(下一节会细说)

七、从组件开发思考:如何更好地被依赖

上面两节我们从 webpackvite 大致了解了 web 应用构建过程中对依赖的处理。

那么,当我们开发组件时,要怎么做才能更好地扮演自己作为 被依赖者 的角色呢?

7.1 组件应该输出什么格式?

大多数情况下:ESMUMD 就够了,如果你的组件需要在 node.js 环境运行,那可能还需要加上 CommonJS 格式。

按照本文之前的说法,两种格式分别应对两种场景:

  • ESM: 作为 一堆代码 被引用。
  • UMD: 作为 一个声明 的实际支撑,被用作 CDN 引入页面。

因为 rollup.js/vite 默认支持输出 ESMUMD,所以 rollup/vite 实在是开发组件的利器。

值得一提的是,rollup.js 默认行为会把所有的模块打包到一个 js 文件里,这行为显然不符合当下 按需加载 的思路。

因此,通过 preserveModules: true 配置选项,可以让 rollup 只编译,不打包,输出完美的散装 esm 文件格式。

7.2 在组件内如何 只声明、不打包

这是组件开发者永远无法绕开的问题,因为你必须想清楚。

你开发 Element Plus,不可能内置一套 vue3 吧?按照本文【第二节】的描述,作为一个组件,你应该正确地 声明自己的依赖

  • 输出 ESM:通过 rollup external 配置和 package.json dependencies

    rollupexternal,和 webpackexternals 的作用类似,但存在差异。

    rollup external 的作用是:指定部分依赖不打包到制品中,但是在代码中保留 import xxx from 'bar' 这样的语句。

    为了配合这个语法,我们应该把实际依赖声明到 package.jsondependencies 中。

    这样当其他应用依赖组件时,会按照【本文第二节】的内容进行安装,并从 node_modules 中去寻找依赖。

  • 输出 UMD:通过 rollup output.globals 配置。

    rollupoutput.globals,和 webpackexternals 是真的像!

    // 这是webpack的externals
    module.exports = {
    //...
    externals: {
      jquery: 'jQuery',
      },
    };
    
    // 这是rollup的globals
    export default {
      output: {
        globals: {
          jquery: 'jQuery'
        }
      }
    };
    

    不仅写法像,它们的作用也像:

    • 被声明 globals 的库,不会被打包到制品中。
    • 被引用时会去浏览器的 window 上通过别名寻找。

通过以上两个思路,可以成功解决组件内 只声明、不打包 的需求。

八、总结

通过上面的总结,我们对 前端依赖 有了一个 较为体系的认识 。不妨试试回答这几个问题:

  • 什么是依赖?是一堆代码,还是一个声明?
  • semver 是什么?
  • npm install 时,是怎么处理不同版本号的?都安装在哪?
  • dependenciesdevDependencies 在表现上有什么本质区别?
  • 为什么 web 端的依赖更加复杂?
  • webpackvite 在制品格式上有啥区别?都是怎么处理依赖的?
  • 开发组件时应该如何正确处理依赖?

九、关于我

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

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

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

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿