开篇词 像架构师一样思考,突破技术成长瓶颈

透过工程基建,架构有迹可循。你好,我是侯策(LucasHC),目前任职于某互联网独角兽公司,带领 6 条业务线前端团队,负责架构设计和核心开发、工程方案调研和选型,以及团队管理、人才梯队建设等工作。

从海外开启职业生涯、浸淫工匠般的 Coding 规范打磨,到深入国内一线大厂接受亿级流量的洗礼,我的工作方向始终没有离开前端开发。

前端开发是一个庞大的体系,纷杂的知识点铸成了一张信息密度极高的图谱。通过下面这张选自《Front-end Developer Handbook 2019》中的“前端技术学习路线图”,你可以清楚地看见前端开发的全貌。

前端技术学习路线图(来源:https://github.com/kamranahmedse/developer-roadmap)

在开发中,一行代码就可能触发宿主引擎的性能瓶颈;团队中的代码量几何级数式增长,可能就愈发尾大不掉,掣肘业务的发展。这些技术环节,或宏观或微观,都与工程化基建、架构设计息息相关。

如何打造一个顺滑的工程化流程,为研发效率不断助力?如何建设一个稳定可靠的基础设施,为业务产出保驾护航?这些问题我在多年的工作中反复思考,不断结合实践,如今也有一些经验和感悟。

但事实上,让我将这些积累幻化成文字是需要一个契机的,我先从做这个专栏的初心,以及专栏内容涉及的技术谈起。

求贤若渴的伯乐和凤毛麟角的人才

作为团队管理者,一直以来我都被人才招聘所困扰。经历了数百场面试,我看到了太多千篇一律的“皮囊”:

  • 「我精通 Vue.js,看过 Vue.js 源码」=== 「我能熟记Object.defineProperty/ Proxy,也知道发布订阅模式」

  • 「我精通 AST」===「我知道 AST 是抽象语法树,知道能用它做些什么」

  • 「我熟练使用并了解 Babel」===「我能记清楚很多 Babel 配置项,甚至默写出 Babel Plugin 模板代码」

当知识技术成为应试八股文,人才招聘就沦为“面试造火箭,工作拧螺丝”的逢场作戏。对于上述问题,我不禁多问:

  • 「你知道 Vue.js 完整版和运行时版本的区别吗?」

如果你不理解「Vue.runtime.js 运行时版本不包含模板编译器」,就大概率无法说清 Vue 在模板编译环节具体做了什么。如果只知道几个 APIs 实现数据劫持和发布订阅模式,又何谈精通原理?

  • 「请你手写一个“匹配有效括号”算法?」

如果连 LeetCode 上 easy 难度的编译原理相关算法题都无法做出,那么何谈分词、AST 这些概念?

  • 「如何设计一个 C 端 Polyfill 方案?」

如果不清楚@babel/preset-envuseBuiltIns不同配置背后的设计理念,又何谈了解 Babel,更别说设计一个性能更好的降级方案。

另一方面,我很理解求职者也面临困惑

  • 我该如何避免相似的工作做三年,而不是具备了三年的工作经验?

  • 我该如何从繁杂且千篇一律的业务需求中抽身出来,有时间总结提高自己?

  • 我该如何为团队带来更大的价值,体现经验能力?

为了破局,焦虑的开发者渐渐成为“短期速成知识”的收集者。你以为收藏的是知识,其实收藏的是“知道”,你以为掌握了知识,其实只是囤积了一堆“知道”。

于是,近些年我也一直在思考:“如何抽象出真正有价值的开发知识”,“如何发现并解决技术成长瓶颈,培养人才”。因此,我将自己在海外和 BAT 服务多年积累的经验分享给大家,把长时间以来我认为最有价值的信息系统性整理输出——这正是我做这个专栏的初心

从前端工程化基建和架构设计的价值谈起

从当前招聘情况和开发社区的现象上看,短平快、碎片化的内容(比如快速搞定“面经题目”)很容易演变成跳槽加薪的兴奋剂,但是在某种程度上它只能成为缓解焦虑的精神鸦片。

试想,如果你资质平平,又缺少团队中“大牛”的指点,工作内容就是在已有项目中写几个页面或运营活动,如此往复技术无法提高,三四年后和应届校招生也许并无差别。

这种情况出现的主要原因还是大部分开发者无法接触到好项目。这里的“好项目”是指:从 0 到 1 打造应用的基础建设、制定应用的工程化方案、实现应用的构建和发布流程、设计应用中公共方法和底层架构。系统性地研究这些知识,才能真正打通开发者“任督二脉”,实现个人和团队更大的价值

我把这样的内容总结定义为:前端工程化基建和架构设计

它是每位开发者成长道路上的稀缺资源。一轮又一轮的业务需求是烦琐而机械的,工程化基建和架构设计却是万丈高楼的根基,是巨型航母的引擎和发动机,是区分一般开发者和一流架构师的分水岭。因此,前端工程化基建和架构设计的价值对于个人、对于业务,更是不言而喻。

我理解的“前端工程化基建和架构设计”

我们知道,前端目前处在前所未有的地位高度:前端职位既收获着快速发展,也迎接着批量劣汰;前端技术有着与生俱来的混乱,也有着与之抗衡的规范。这都对前端工程化和基础建设提出了更高的挑战,对技术架构设计能力提出了更高的要求。

对于业务来说,在工程化基建当中:

  • 团队并非一个人单打独斗,那么如何设计工作流程,如何打造一个众人皆赞的项目根基?

  • 项目依赖纷繁复杂,如何做好依赖管理和公共库管理?

  • 如何深入理解框架,真正做到框架的精通和技术选型的准确拿捏?

  • 从最基本的网络请求库说起,如何设计一个稳定灵活的多端 Fetch 库?

  • 如何借力 Low Code / No Code 技术,实现越来越智能的应用搭建方案?

  • 如何统一中后台项目架构,实现跨业务线的产研效率提升?

  • 如何开发设计一套适合业务的组件库,封装分层样式,最大限度做到复用,提升开发效率?

  • 如何制定跨端方案,Write Once,Run Everywhere 是否真的可行?

  • 如何处理各种模块化规范,以及精确做到代码拆分的最佳实践?

  • 如何区分开发边界,比如前端如何更好地利用 Node.js 方案开疆扩土?

这都直接决定了前端的业务价值,体现了前端团队的技术能力。

那具体什么是我心中的“前端架构设计和工程化建设”呢?

我以身边常见的一些小细节作为例子:不管是菜鸟还是经验丰富的开发者,都有过被配置文件搞到焦头烂额的时候,一不小心就引起了命令行报错,编译不通过,终端上几行英文字母铺满 warning / error。

也许你可以通过搜索引擎找到临时解决方案,匆匆忙忙又重新回到业务开发中追赶工期。但报错的本源到底是什么,究竟什么是真正高效的解决方案?如果不深入探究,你很快还会因为类似的情况浪费大把时间,同时技术能力毫无提升。

再试想,对于开发时遇见的一些诡异问题,你也许删除一次node_moudles,并重新执行npm install,然后发现“重启大法”有时候真能奇迹般地解决问题。可是你对其中原理却鲜有探究,也不清楚这是否是一种优雅的解决方案。

又或者,为了实现一个通用功能(也许就是为了找到一个函数的参数用法),你不得不翻看项目中“屎山代码”,浪费了大把时间。可是面对历史代码,你却完全不敢重构,日积月累“历史”逐渐成为“天坑”,“屎山代码”成为业务桎梏。

基于多年对一线开发的观察,以及对人才成长的思考,我心中的“前端工程化基建和架构设计”不是简单的思维模式输出,不是纯粹阳春白雪的理论,也不是社区搜索即得的 Webpack 配置罗列和原理复述。而是从项目中的痛点提取基础建设的意义,从个人发展瓶颈总结工程化架构和底层设计,于是这门课程的内容呼之欲出。

如何学习前端工程化基建和架构设计?

事实上,前端工程化基建和架构设计相关话题在网上的内容少之又少。我几乎翻遍社区上所有的相关联课程,它们更多是对 Webpack 的配置讲解、相关源码的复制粘贴,或 npm 基础用法的列举等。

我一直在思考,什么样的内容能够帮助读者突破“会用”的层面,能从更高的角度看待问题。在本课程中,我主要从以下几个方向展开讲解:

  • 前端工程化管理工具

  • 现代化前端开发和架构生态

  • 核心框架原理与代码设计模式

  • 前端架构设计实战

  • 前端全链路——Node.js 全栈开发

在第一部分前端工程化管理工具中,我会以 npm 和 Yarn 包管理工具切入工程化主题,以 Webpack 和 Vite 构建工具加深读者对工程化的理解。事实上,工具的背后是原理,因此我并不会枯燥地枚举某项工具的优缺点和基本使用方式,而是深入几项极具代表性的技术原理和演变,我认为只有吃透这些内容,才能真正理解工程化架构和工具选型。希望通过第一部分,你能够感知到如何刨根问底地学习,如何像一名架构师一样思考。

在第二部分现代化前端开发和架构生态中,我将一网打尽那些大部分开发者每天都会接触到,但很少真正理解的知识点。希望通过第二部分,你能够真正意识到:Webpack 工程师并不是写写配置文件那么简单,Babel 生态体系也不是使用 AST 技术玩转编译原理而已。这部分内容能够帮助你培养前端工程化和基础建设的整体思想,这些知识也是设计一个公共库、主导一项技术方案的基础。

第三部分我们一起来体验经典代码,设计模式和数据结构的艺术。通过再学习经典思想和剖析源码内容,相信你能够有新的收获

在第四部分架构实战搭建中,我会一步一步带领大家从 0 到 1 实现一个完整的应用项目或公共库。这些工程实践并不是社区上泛滥的 Todo MVC,而是代表先进设计理念的现代化工程架构项目(比如设计实现前端 + 移动端离线包方案等)。同时在这个环节,我也会对编译和构建、部署和发布这一热门话题进行重点讨论。

最后一部分,我们以实战的方式,灵活运用并实践 Node.js。这一部分不会讲解 Node.js 的基础内容,这也就需要你先储备相关知识。我们的重点会放在 Node.js 的应用层面和建设发展话题上,比如我会带你设计并完成一个真正意义上的企业级网关,其中涉及网络知识、Node.js 理论知识、权限和代理知识等,这会是对前端开发能力的综合培养;再比如我会带你研究并实现一个完善可靠的 Node.js 服务系统,它可能涉及异步消息队列、数据存储,以及相关微服务等传统后端知识, 让你能够真正在团队中落地 Node.js 技术,不断开疆扩土。

总之,这门课程内容很多,干货满满。

客观来说,我绝不相信一本“武功秘籍”就能让每个人一路打怪升级、一步登天。但我更想把这个专栏当作一个和你交流的机会,输出自己经验积累的同时,能帮助到每一个人。你准备好了吗?来和我一起,像架构师一样思考。


01 npm 安装机制及企业级部署私服原理

前端工程化离不开 npm(node package manager) 或者 Yarn 这些管理工具。npm 或 Yarn 在工程项目中,除了负责依赖的安装和维护以外,还能通过 npm scripts 串联起各个职能部分,让独立的环节自动运转起来。

无论是 npm 还是 Yarn,它们的体系都非常庞大,在使用过程中你很可能产生如下疑问:

  • 项目依赖出现问题时,删除大法好,即删除 node_modules 和 lockfiles,再重新 install,这样操作是否存在风险?

  • 把所有依赖都安装到 dependencies 中,不区分 devDependencies 会有问题吗?

  • 我们的应用依赖了公共库 A 和公共库 B,同时公共库 A 也依赖了公共库 B,那么公共库 B 会被多次安装或重复打包吗?

  • 一个项目中,既有人用 npm,也有人用 Yarn,这会引发什么问题?

  • 我们是否应该提交 lockfiles 文件到项目仓库呢?

接下来的 01 ~ 03 讲我们就进一步聊一聊这些问题!

npm 内部机制和核心原理

我们先来看看 npm 的核心目标:

Bring the best of open source to you, your team and your company.
给你和你的团队、你的公司带来最好的开源库和依赖。

通过这句话,我们可以知道 npm 最重要的一环是安装和维护依赖。在平时开发中,“删除 node_modules,重新 npm install”是一个百试不爽的解决 npm 安装类问题的方法。但是其中的作用原理是什么?这样的操作是否规范呢?

这一讲,我们就先从 npm 内部机制出发来剖析此类问题。了解完安装机制和原理,我相信你对于工程中依赖的问题,将有一个更加体系化的认知。

npm 的安装机制和背后思想

npm 的安装机制非常值得探究。Ruby 的 Gem、Python 的 pip 都是全局安装,但是 npm 的安装机制秉承了不同的设计哲学。

它会优先安装依赖包到当前项目目录,使得不同应用项目的依赖各成体系,同时还减轻了包作者的 API 兼容性压力,但这样做的缺陷也很明显:如果我们的项目 A 和项目 B,都依赖了相同的公共库 C,那么公共库 C 一般都会在项目 A 和项目 B 中,各被安装一次。这就说明,同一个依赖包可能在我们的电脑上进行多次安装

当然,对于一些工具模块比如 supervisor 和 gulp,你仍然可以使用全局安装模式,这样方便注册 path 环境变量,我们可以在任何地方直接使用 supervisor、 gulp 这些命令。(不过,一般还是建议不同项目维护自己局部的 gulp 开发工具以适配不同项目需求。)

下面,言归正传,我们通过流程图来分析 npm install 的安装机制。

npm install 安装流程图

npm install 执行之后,首先,检查并获取 npm 配置,这里的优先级为:项目级的 .npmrc 文件 > 用户级的 .npmrc 文件> 全局级的 .npmrc 文件 > npm 内置的 .npmrc 文件

然后检查项目中是否有 package-lock.json 文件。

如果有,则检查 package-lock.json 和 package.json 中声明的依赖是否一致:

  • 一致,直接使用 package-lock.json 中的信息,从缓存或网络资源中加载依赖;

  • 不一致,按照 npm 版本进行处理(不同 npm 版本处理会有不同,具体处理方式如图所示)。

如果没有,则根据 package.json 递归构建依赖树。然后按照构建好的依赖树下载完整的依赖资源,在下载时就会检查是否存在相关资源缓存:

  • 存在,则将缓存内容解压到 node_modules 中;

  • 否则就先从 npm 远程仓库下载包,校验包的完整性,并添加到缓存,同时解压到 node_modules。

最后生成 package-lock.json。

构建依赖树时,当前依赖项目不管其是直接依赖还是子依赖的依赖,都应该按照扁平化原则,优先将其放置在 node_modules 根目录(最新版本 npm 规范)。在这个过程中,遇到相同模块就判断已放置在依赖树中的模块版本是否符合新模块的版本范围,如果符合则跳过;不符合则在当前模块的 node_modules 下放置该模块(最新版本 npm 规范)。

我给出的流程图中有标注更细节的内容,这里就不再赘述了。你要格外注意图中标明的 npm 不同版本的不同处理情况,并学会从这种“历史问题”中总结 npm 使用的团队最佳实践:同一个项目团队,应该保证 npm 版本的一致

前端工程中,依赖嵌套依赖,一个中型项目中 node_moduels 安装包可能就已经是海量的了。如果安装包每次都通过网络下载获取,无疑会增加安装时间成本。对于这个问题,缓存始终是一个好的解决思路,我们接下来看看 npm 自己的缓存机制。

npm 缓存机制

对于一个依赖包的同一版本进行本地化缓存,是当代依赖包管理工具的一个常见设计。使用时要先执行以下命令:

npm config get cache

得到配置缓存的根目录在 /Users/cehou/.npm( Mac OS 中,npm 默认的缓存位置) 当中。我们 cd 进入 /Users/cehou/.npm 中可以发现_cacache文件。事实上,在 npm v5 版本之后,缓存数据均放在根目录中的_cacache文件夹中。

_cacache文件)

我们可以使用以下命令清除 /Users/cehou/.npm/_cacache 中的文件:

 npm cache clean --force

你可以点击这里看看其中对应的 npm 源码。

接下来打开_cacache文件,看看 npm 缓存了哪些东西,一共有 3 个目录:

  • content-v2

  • index-v5

  • tmp

其中 content-v2 里面基本都是一些二进制文件。为了使这些二进制文件可读,我们把二进制文件的扩展名改为 .tgz,然后进行解压,得到的结果其实就是我们的 npm 包资源。

而 index-v5 文件中,我们采用跟刚刚一样的操作就可以获得一些描述性的文件,事实上这些内容就是 content-v2 里文件的索引。

这些缓存如何被储存并被利用的呢?

这就和 npm install 机制联系在了一起。当 npm install 执行时,通过pacote把相应的包解压在对应的 node_modules 下面。npm 在下载依赖时,先下载到缓存当中,再解压到项目 node_modules 下。pacote 依赖npm-registry-fetch来下载包,npm-registry-fetch 可以通过设置 cache 属性,在给定的路径下根据IETF RFC 7234生成缓存数据。

接着,在每次安装资源时,根据 package-lock.json 中存储的 integrity、version、name 信息生成一个唯一的 key,这个 key 能够对应到 index-v5 目录下的缓存记录。如果发现有缓存资源,就会找到 tar 包的 hash,根据 hash 再去找缓存的 tar 包,并再次通过pacote把对应的二进制文件解压到相应的项目 node_modules 下面,省去了网络下载资源的开销。

注意,这里提到的缓存策略是从 npm v5 版本开始的。在 npm v5 版本之前,每个缓存的模块在 ~/.npm 文件夹中以模块名的形式直接存储,储存结构是:{cache}/{name}/{version}

了解这些相对底层的内容可以直接帮助开发者排查 npm 相关问题,这也是区别一般程序员和架构师的细节之一。能不能在理论内容上多走一步,也决定了我们的技术能力能不能更上一层楼。这里我们进行了初步学习,我希望这也可以成为你探究底层的开始。

npm 不完全指南

接下来,我想介绍几个实用的 npm 小技巧,这些技巧并不包括“npm 快捷键”等常见内容,主要是从工程开发角度,聚焦更广泛的内容。这里我不会花大量篇幅讲解 npm 命令内容,这些知识你可以直接通过 npm cli 官方文档获得。

下面,我将从 npm 使用技巧以及一些常见使用误区来展开。

自定义 npm init

npm 支持我们自定义 npm init,快速创建一个符合自己需求的自定义项目。想象一下,npm init 命令本身并不复杂,它其实就是调用 shell 脚本输出一个初始化的 package.json 文件。那么相应地,我们要自定义 npm init 命令,就是写一个 node 脚本而已,它的 module.exports 即为 package.json 配置内容。

为了实现更加灵活的自定义功能,我们可以使用 prompt() 方法,获取用户输入并动态产生的内容:

const desc = prompt('请输入项目描述', '项目描述...')
module.exports = {key: 'value',name: prompt('name?', process.cwd().split('/').pop()),version: prompt('version?', '0.0.1'),description: desc,main: 'index.js',repository: prompt('github repository url', '', function (url) {if (url) {run('touch README.md');run('git init');run('git add README.md');run('git commit -m "first commit"');run(`git remote add origin ${url}`);run('git push -u origin master');}return url;})
}

假设该脚本名为 .npm-init.js,我们执行下述命令来确保 npm init 所对应的脚本指向正确的文件:

npm config set init-module ~\.npm-init.js

更多信息可见:npm-init。

我们也可以通过配置 npm init 默认字段来自定义 npm init 的内容:

npm config set init.author.name "Lucas"
npm config set init.author.email "lucasXXXXXX@gmail.com"
npm config set init.author.url "lucasXXXXX.com"
npm config set init.license "MIT"

更多信息见:npm-config。

利用 npm link,高效率在本地调试以验证包的可用性

当我们开发一个公共包时,总会有这样的困扰:假如我开发一个组件库,某个组件开发完成之后,如何验证该组件能在我的业务项目中正常运行呢?

除了写一个完备的测试以外,常见的思路就是在组件库开发中,设计 examples 目录或者一个 playground,启动一个开发服务,以验证组件的运行情况

然而真实应用场景是多种多样的,如果能在某个项目中率先尝试就太好了。但我们又不能发布一个不安全的包版本供业务项目使用。另一个“笨”方法是,手动复制粘贴组件并打包产出到业务项目的 node_modules 中进行验证,但是这种做法既不安全也会使得项目混乱,变得难以维护,同时过于依赖手工执行,这种操作非常原始。

那么如何高效率在本地调试以验证包的可用性呢?这个时候,我们就可以使用 npm link。简单来说,它可以将模块链接到对应的业务项目中运行

我们来看一个具体场景,假设你正在开发项目 project 1,其中有个包 package 1,对应 npm 模块包名称是 npm-package-1,我们在 package 1 项目中加入了新功能 feature A,现在要验证在 project 1 项目中能否正常使用 package 1 的 feature A,你应该怎么做?

我们先在 package 1 目录中,执行 npm link,这样 npm link 通过链接目录和可执行文件,实现 npm 包命令的全局可执行。

然后在 project 1 中创建链接,执行 npm link npm-package-1 命令时,它就会去 /usr/local/lib/node_modules/ 这个路径下寻找是否有这个包,如果有就建立软链接。

这样一来,我们就可以在 project 1 的 node_module 中会看到链接过来的模块包 npm-package-1,此时的 npm-package-1 就带有最新开发的 feature A,这样一来就可以在 project 1 中正常开发调试 npm-package-1。当然别忘了,调试结束后可以执行 npm unlink 以取消关联。

从工作原理上总结,npm link 的本质就是软链接,它主要做了两件事:

  • 为目标 npm 模块(npm-package-1)创建软链接,将其链接到全局 node 模块安装路径 /usr/local/lib/node_modules/ 中;

  • 为目标 npm 模块(npm-package-1)的可执行 bin 文件创建软链接,将其链接到全局 node 命令安装路径 /usr/local/bin/ 中。

通过刚才的场景,你可以看到:npm link 能够在工程上解决依赖包在任何一个真实项目中进行调试的问题,并且操作起来更加方便快捷

npx 的作用

npx 由 npm v5.2 版本引入,解决了 npm 的一些使用快速开发、调试,以及项目内使用全局模块的痛点。

在传统 npm 模式下,如果我们需要使用代码检测工具 ESLint,就要先通过 npm install 安装:

npm install eslint --save-dev

然后在项目根目录下执行:

./node_modules/.bin/eslint --init
./node_modules/.bin/eslint yourfile.js

或者通过项目脚本和 package.json 的 npm scripts 字段调用 ESLint。

而使用 npx 就简单多了,你只需要下面 2 个操作步骤:

npx eslint --init
npx eslint yourfile.js

为什么 npx 操作起来如此便捷呢?

这是因为它可以直接执行 node_modules/.bin 文件夹下的文件。在运行命令时,npx 可以自动去 node_modules/.bin 路径和环境变量 $PATH 里面检查命令是否存在,而不需要再在 package.json 中定义相关的 script。

npx 另一个更实用的好处是:npx 执行模块时会优先安装依赖,但是在安装执行后便删除此依赖,这就避免了全局安装模块带来的问题

运行如下命令后,npx 会将 create-react-app 下载到一个临时目录,使用以后再删除:

npx create-react-app cra-project

更多关于 npx 的介绍你可以去官网进一步查看。

现在,你已经对 npm 有了一个初步了解,我们接下来一同看看 npm 实操部分:多源镜像和企业级部署私服原理。

npm 多源镜像和企业级部署私服原理

npm 中的源(registry),其实就是一个查询服务。以 npmjs.org 为例,它的查询服务网址是 https://registry.npmjs.org/。这个网址后面跟上模块名,就会得到一个 JSON 对象,里面是该模块所有版本的信息。比如,访问 https://registry.npmjs.org/react,就会看到 react 模块所有版本的信息。

我们可以通过npm config set命令来设置安装源或者某个 scope 对应的安装源,很多企业也会搭建自己的 npm 源。我们常常会碰到需要使用多个安装源的项目,这时就可以通过 npm-preinstall 的钩子,通过 npm 脚本,在安装公共依赖前自动进行源切换:

"scripts": {"preinstall": "node ./bin/preinstall.js"
}

其中 preinstall.js 脚本内容,具体逻辑为通过 node.js 执行npm config set命令,代码如下:

require(' child_process').exec('npm config get registry', function(error, stdout, stderr) {if (!stdout.toString().match(/registry\.x\.com/)) {exec('npm config set @xscope:registry https://xxx.com/npm/')}
})

国内很多开发者使用的 nrm(npm registry manager)是 npm 的镜像源管理工具,使用它可以快速地在 npm 源间切换,这当然也是一种选择。

你的公司是否也正在部署一个私有 npm 镜像呢?你有没有想过公司为什么要这样做呢?

虽然 npm 并没有被屏蔽,但是下载第三方依赖包的速度依然较缓慢,这严重影响 CI/CD 流程或本地开发效率。部署镜像后,一般可以确保高速、稳定的 npm 服务,而且使发布私有模块更加安全。除此之外,审核机制也可以保障私服上的 npm 模块质量和安全

那么,如何部署一个私有 npm 镜像呢?

现在社区上主要有 3 种工具来搭建 npm 私服:nexus、verdaccio 以及 cnpm。

它们的工作原理相同,我们可以通过 nexus 的架构示例简单了解一下:

nexus 架构示例图

nexus 工作在 client 和外部 npm 之间,并通过 group repository 合并 npm 仓库以及私有仓库,这样就起到了代理转发的作用。

了解了 npm 私服的原理,我们就不畏惧任何“雷区”。这部分我也总结了两个社区上常见的问题。

npm 配置作用优先级

npm 可以通过默认配置帮助我们预设好 npm 对项目的影响动作,但是 npm 的配置优先级需要开发者确认了解。

如下图所示,优先级从左到右依次降低。我们在使用 npm 时需要了解 npm 的设置作用域,排除干扰范围,以免一顿骚操作之后,并没有找到相应的起作用配置。

优先级排序示意图

npm 镜像和安装问题

另外一个常见的问题就是 npm 镜像和依赖安装,关于 npm 镜像和依赖安装问题,归根到底还是网络环境导致的,建议有条件的情况下还是从网络层面解决问题

如果没有条件,也不要紧,办法总比困难多,可以通过设置安装源镜像来解决,这就需要紧跟社区方案,刨根究底了。这里推荐一篇文章:聊聊 npm 镜像那些险象环生的坑,文章中有更详细的内容,你可以看看。

结语

关于 npm 的核心理念及安装机制,我们暂且分析到这里。这一讲,我们梳理了 npm 安装逻辑,在了解其安装原理的基础上,对 npm 一些常见使用误区以及使用技巧进行了分析;另外我们也具体了解了 npm 多源镜像和企业级部署私服原理。

各种环节并不复杂,但是却往往被开发者忽略,导致项目中开发受阻或者架构混乱。本课时,我们也深入多处源码内容,希望对你设计一个完整的工程流程机制有所启发。这里我也给大家留一个思考题:cnpm 是什么,它有什么意义?欢迎你在留言区分享你的观点。

关于 npm 和 Yarn 的更多内容,我们将在下一讲中继续进行,欢迎你继续阅读。


02 Yarn 的安装理念及如何破解依赖管理困境

01 讲我们讲了 npm 的技巧和原理,但其实在前端工程化这个主题上除了 npm,还有不可忽视的 Yarn。

Yarn 是一个由 Facebook、Google、Exponent 和 Tilde 构建的新的 JavaScript 包管理器。它的出现是为了解决历史上 npm 的某些不足(比如 npm 对于依赖的完整性和一致性保障,以及 npm 安装速度过慢的问题等),虽然 npm 目前经过版本迭代汲取了 Yarn 一些优势特点(比如一致性安装校验算法等),但我们依然有必要关注 Yarn 的思想和理念。

Yarn 和 npm 的关系,有点像当年的 Io.js 和 Node.js,殊途同归,都是为了进一步解放和优化生产力。这里需要说明的是,不管是哪种工具,你应该做的就是全面了解其思想,优劣胸中有数,这样才能驾驭它,为自己的项目架构服务

当 npm 还处在 v3 时期时,一个叫作 Yarn 的包管理方案横空出世。2016 年,npm 还没有 package-lock.json 文件,安装速度很慢,稳定性也较差,而 Yarn 的理念很好地解决了以下问题。

  • 确定性:通过 yarn.lock 等机制,保证了确定性。即不管安装顺序如何,相同的依赖关系在任何机器和环境下,都可以以相同的方式被安装。(在 npm v5 之前,没有 package-lock.json 机制,只有默认并不会使用的npm-shrinkwrap.json。)

  • 采用模块扁平安装模式:将依赖包的不同版本,按照一定策略,归结为单个版本,以避免创建多个副本造成冗余(npm 目前也有相同的优化)。

  • 网络性能更好:Yarn 采用了请求排队的理念,类似并发连接池,能够更好地利用网络资源;同时引入了更好的安装失败时的重试机制。

  • 采用缓存机制,实现了离线模式(npm 目前也有类似实现)。

我们先来看看 yarn.lock 结构:

"@babel/cli@^7.1.6", "@babel/cli@^7.5.5":version "7.8.4"resolved "http://npm.in.zhihu.com/@babel%2fcli/-/cli-7.8.4.tgz#505fb053721a98777b2b175323ea4f090b7d3c1c"integrity sha1-UF+wU3IamHd7KxdTI+pPCQt9PBw=dependencies:commander "^4.0.1"convert-source-map "^1.1.0"fs-readdir-recursive "^1.1.0"glob "^7.0.0"lodash "^4.17.13"make-dir "^2.1.0"slash "^2.0.0"source-map "^0.5.0"optionalDependencies:chokidar "^2.1.8"

该结构整体和 package-lock.json 结构类似,只不过 yarn.lock 并没有使用 JSON 格式,而是采用了一种自定义的标记格式,新的格式仍然保持了较高的可读性。

相比 npm,Yarn 另外一个显著区别是 yarn.lock 中子依赖的版本号不是固定版本。这就说明单独一个 yarn.lock 确定不了 node_modules 目录结构,还需要和 package.json 文件进行配合。

其实,不管是 npm 还是 Yarn,说到底它们都是一个包管理工具,在项目中如果想进行 npm/Yarn 切换,并不是一件麻烦的事情。甚至还有一个专门的 synp 工具,它可以将 yarn.lock 转换为 package-lock.json,反之亦然。

关于 Yarn 缓存,我们可以通过这个命令查看缓存目录,并通过目录查看缓存内容:

yarn cache dir

值得一提的是,Yarn 默认使用 prefer-online 模式,即优先使用网络数据。如果网络数据请求失败,再去请求缓存数据。

最后,我们来看一看一些区别于 npm,Yarn 所独有的命令:

yarn import
yarn licenses
yarn pack
yarn why
yarn autoclean

npm 独有的命令是:npm rebuild

现在,你已经对 Yarn 有了一个初步了解,接下来我们来分析一下 Yarn 的安装机制和思想。

Yarn 安装机制和背后思想

上一讲我们已经介绍过了 npm 安装机制,这里我们再来看一下 Yarn 的安装理念。简单来说,Yarn 的安装过程主要有以下 5 大步骤:

检测(checking)→ 解析包(Resolving Packages) → 获取包(Fetching Packages)→ 链接包(Linking Packages)→ 构建包(Building Packages)

Yarn 安装流程图

检测包(checking)

这一步主要是检测项目中是否存在一些 npm 相关文件,比如 package-lock.json 等。如果有,会提示用户注意:这些文件的存在可能会导致冲突。在这一步骤中,也会检查系统 OS、CPU 等信息

解析包(Resolving Packages)

这一步会解析依赖树中每一个包的版本信息。

首先获取当前项目中 package.json 定义的 dependencies、devDependencies、optionalDependencies 的内容,这属于首层依赖。

接着采用遍历首层依赖的方式获取依赖包的版本信息,以及递归查找每个依赖下嵌套依赖的版本信息,并将解析过和正在解析的包用一个 Set 数据结构来存储,这样就能保证同一个版本范围内的包不会被重复解析。

  • 对于没有解析过的包 A,首次尝试从 yarn.lock 中获取到版本信息,并标记为已解析;

  • 如果在 yarn.lock 中没有找到包 A,则向 Registry 发起请求获取满足版本范围的已知最高版本的包信息,获取后将当前包标记为已解析。

总之,在经过解析包这一步之后,我们就确定了所有依赖的具体版本信息以及下载地址。

解析包获取流程图

获取包(Fetching Packages)

这一步我们首先需要检查缓存中是否存在当前的依赖包,同时将缓存中不存在的依赖包下载到缓存目录。说起来简单,但是还是有些问题值得思考。

比如:如何判断缓存中是否存在当前的依赖包?其实 Yarn 会根据 cacheFolder+slug+node_modules+pkg.name 生成一个 path,判断系统中是否存在该 path,如果存在证明已经有缓存,不用重新下载。这个 path 也就是依赖包缓存的具体路径

对于没有命中缓存的包,Yarn 会维护一个 fetch 队列,按照规则进行网络请求。如果下载包地址是一个 file 协议,或者是相对路径,就说明其指向一个本地目录,此时调用 Fetch From Local 从离线缓存中获取包;否则调用 Fetch From External 获取包。最终获取结果使用 fs.createWriteStream 写入到缓存目录下。

获取包流程图

链接包(Linking Packages)

上一步是将依赖下载到缓存目录,这一步是将项目中的依赖复制到项目 node_modules 下,同时遵循扁平化原则。在复制依赖前,Yarn 会先解析 peerDependencies,如果找不到符合 peerDependencies 的包,则进行 warning 提示,并最终拷贝依赖到项目中。

这里提到的扁平化原则是核心原则,我也会在后面内容进行详细的讲解。

链接包解析流程图

构建包(Building Packages)

如果依赖包中存在二进制包需要进行编译,会在这一步进行。

了解了 npm 和 Yarn 的安装原理还不是“终点”,因为一个应用项目的依赖错综复杂。接下来我将从“依赖地狱”说起,帮助你加深对依赖机制相关内容的理解,以便在开发生产中灵活运用。

破解依赖管理困境

早期 npm(npm v2)的设计非常简单,在安装依赖时将依赖放到项目的 node_modules 文件中;同时如果某个直接依赖 A 还依赖其他模块 B,作为间接依赖,模块 B 将会被下载到 A 的 node_modules 文件夹中,依此递归执行,最终形成了一颗巨大的依赖模块树。

这样的 node_modules 结构,的确简单明了、符合预期,但对大型项目在某些方面却不友好,比如可能有很多重复的依赖包,而且会形成“嵌套地狱”。

那么如何理解“嵌套地狱”呢?

  • 项目依赖树的层级非常深,不利于调试和排查问题;

  • 依赖树的不同分支里,可能存在同样版本的相同依赖。比如直接依赖 A 和 B,但 A 和 B 都依赖相同版本的模块 C,那么 C 会重复出现在 A 和 B 依赖的 node_modules 中。

这种重复问题使得安装结果浪费了较大的空间资源,也使得安装过程过慢,甚至会因为目录层级太深导致文件路径太长,最终在 Windows 系统下删除 node_modules 文件夹出现失败情况

因此 npm v3 之后,node_modules 的结构改成了扁平结构,按照上面的例子(项目直接依赖模块 A,A 还依赖其他模块 B),我们得到下面的图示:

npm 不同版本的安装结构图 ①

当项目新添加了 C 依赖,而它依赖另一个版本的 B v2.0。这时候版本要求不一致导致冲突,B v2.0 没办法放在项目平铺目录下的 node_moduls 文件当中,npm v3 会把 C 依赖的 B v2.0 安装在 C 的 node_modules 下:

npm 不同版本的安装结构图 ②

接下来,在 npm v3 中,假如我们的 App 现在还需要依赖一个 D,而 D 也依赖 B v2.0 ,我们会得到如下结构:

npm 安装结构图 ①

这里我想请你思考一个问题:为什么 B v1.0 出现在项目顶层 node_modules,而不是 B v2.0 出现在 node_modules 顶层呢

其实这取决于模块 A 和 C 的安装顺序。因为 A 先安装,所以 A 的依赖 B v1.0 率先被安装在顶层 node_modules 中,接着 C 和 D 依次被安装,C 和 D 的依赖 B v2.0 就不得不安装在 C 和 D 的 node_modules 当中了。因此,模块的安装顺序可能影响 node_modules 内的文件结构

我们继续依赖工程化之旅。假设这时候项目又添加了一个依赖 E ,E 依赖了 B v1.0 ,安装 E 之后,我们会得到这样一个结构:

npm 安装结构图 ②

此时对应的 package.json 中,依赖包的顺序如下:

{A: "1.0",C: "1.0",D: "1.0",E: "1.0"
}

如果我们想更新模块 A 为 v2.0,而模块 A v2.0 依赖了 B v2.0,npm v3 会怎么处理呢?

整个过程是这样的:

  • 删除 A v1.0;

  • 安装 A v2.0;

  • 留下 B v1.0 ,因为 E v1.0 还在依赖;

  • 把 B v2.0 安装在 A v2.0 下,因为顶层已经有了一个 B v1.0。

它的结构如下:

npm 安装结构图 ③

这时模块 B v2.0 分别出现在了 A、C、D 模块下——重复存在了。

通过这一系列操作我们可以看到:npm 包的安装顺序对于依赖树的影响很大。模块安装顺序可能影响 node_modules 内的文件数量

这里一个更理想的依赖结构理应是:

npm 安装结构图 ④

过了一段时间,模块 E v2.0 发布了,并且 E v2.0 也依赖了模块 B v2.0 ,npm v3 更新 E 时会怎么做呢?

  • 删除 E v1.0;

  • 安装 E v2.0;

  • 删除 B v1.0;

  • 安装 B v2.0 在顶层 node_modules 中,因为现在顶层没有任何版本的 B 了。

此时得到图:

npm 安装结构图 ⑤

这时候,你可以明显看到出现了较多重复的依赖模块 B v2.0。我们可以删除 node_modules,重新安装,利用 npm 的依赖分析能力,得到一个更清爽的结构。

实际上,更优雅的方式是使用 npm dedupe 命令,得到:

npm 安装结构图 ⑥

实际上,Yarn 在安装依赖时会自动执行 dedupe 命令。整个优化的安装过程,就是上一讲提到的扁平化安装模式,也是需要你掌握的关键内容

结语

这一讲我们解析了 Yarn 安装原理。

通过本讲内容,你可以发现包安装并不只是从远程下载文件那么简单,这其中涉及缓存、系统文件路径,更重要的是还涉及了安装依赖树的解析、安装结构算法等。

最后,给大家布置一个思考题,npm v7 在 2020 年 10 月刚刚发布,请你总结一下它的新特性,并思考一下为什么要引入这些新的特性?这些新特性背后是如何实现的?欢迎在留言区分享你的观点。


03 CI 环境上的 npm 优化及更多工程化问题解析

前两讲,我们围绕着 npm 和 Yarn 的核心原理展开了讲解,实际上 npm 和 Yarn 涉及项目的方方面面,加之本身设计复杂度较高,这一讲我将继续讲解 CI 环境上的 npm 优化以及更多工程化相关问题。希望通过这一讲的学习你能够学习到 CI 环境上使用包管理工具的方方面面,并能够解决非本地环境下(一般是在容器上)使用包管理工具解决相关问题。

CI 环境上的 npm 优化

CI 环境下的 npm 配置和开发者本地 npm 操作有些许不同,接下来我们一起看看 CI 环境上的 npm 相关优化。

合理使用 npm ci 和 npm install

顾名思义,npm ci 就是专门为 CI 环境准备的安装命令,相比 npm install 它的不同之处在于:

  • npm ci 要求项目中必须存在 package-lock.json 或 npm-shrinkwrap.json;

  • npm ci 完全根据 package-lock.json 安装依赖,这可以保证整个开发团队都使用版本完全一致的依赖;

  • 正因为 npm ci 完全根据 package-lock.json 安装依赖,在安装过程中,它不需要计算求解依赖满足问题、构造依赖树,因此安装过程会更加迅速;

  • npm ci 在执行安装时,会先删除项目中现有的 node_modules,然后全新安装;

  • npm ci 只能一次安装整个项目所有依赖包,无法安装单个依赖包;

  • 如果 package-lock.json 和 package.json 冲突,那么 npm ci 会直接报错,并非更新 lockfiles;

  • npm ci 永远不会改变 package.json 和 package-lock.json。

基于以上特性,我们在 CI 环境使用 npm ci 代替 npm install,一般会获得更加稳定、一致和迅速的安装体验

更多 npm ci 的内容你也可以在官网查看。

使用 package-lock.json 优化依赖安装时间

上面提到过,对于应用项目,建议上传 package-lock.json 到仓库中,以保证依赖安装的一致性。事实上,如果项目中使用了 package-lock.json 一般还可以显著加速依赖安装时间。这是因为package-lock.json 中已经缓存了每个包的具体版本和下载链接,你不需要再去远程仓库进行查询,即可直接进入文件完整性校验环节,减少了大量网络请求

除了上面所述内容,CI 环境上,缓存 node_modules 文件也是企业级使用包管理工具常用的优化做法。

更多工程化相关问题解析

下面这部分,我将通过剖析几个问题,来加深你对这几讲学习概念的理解,以及对工程化中可能遇到的问题进行预演。

为什么要 lockfiles,要不要提交 lockfiles 到仓库?

从 npm v5 版本开始,增加了 package-lock.json 文件。我们知道package-lock.json 文件的作用是锁定依赖安装结构,目的是保证在任意机器上执行 npm install 都会得到完全相同的 node_modules 安装结果

你需要明确,为什么单一的 package.json 不能确定唯一的依赖树:

  • 不同版本的 npm 的安装依赖策略和算法不同;

  • npm install 将根据 package.json 中的 semver-range version 更新依赖,某些依赖项自上次安装以来,可能已发布了新版本。

因此,保证能够完整准确地还原项目依赖,就是 lockfiles 出现的原因。

首先我们了解一下 package-lock.json 的作用机制。上一讲中我们已经解析了 yarn.lock 文件结构,这里我们看下 package-lock.json 的内容举例:

"@babel/core": {"version": "7.2.0","resolved": "http://www.npm.com/@babel%2fcore/-/core-7.2.0.tgz","integrity": "sha1-pN04FJAZmOkzQPAIbphn/voWOto=","dev": true,"requires": {"@babel/code-frame": "^7.0.0",// ...},"dependencies": {"@babel/generator": {"version": "7.2.0","resolved": "http://www.npm.com/@babel%2fgenerator/-/generator-7.2.0.tgz","integrity": "sha1-6vOCH6AwHZ1K74jmPUvMGbc7oWw=","dev": true,"requires": {"@babel/types": "^7.2.0","jsesc": "^2.5.1","lodash": "^4.17.10","source-map": "^0.5.0","trim-right": "^1.0.1"}},// ...}},// ...
}

通过上述代码示例,我们看到:一个 package-lock.json 的 dependency 主要由以下部分构成。

  • Version:依赖包的版本号

  • Resolved:依赖包安装源(可简单理解为下载地址)

  • Integrity:表明包完整性的 Hash 值

  • Dev:表示该模块是否为顶级模块的开发依赖或者是一个的传递依赖关系

  • requires:依赖包所需要的所有依赖项,对应依赖包 package.json 里 dependencies 中的依赖项

  • dependencies:依赖包 node_modules 中依赖的包(特殊情况下才存在)

事实上,并不是所有的子依赖都有 dependencies 属性,只有子依赖的依赖和当前已安装在根目录的 node_modules 中的依赖冲突之后,才会有这个属性。这就涉及嵌套情况的依赖管理,我已经在前文做了说明。

至于要不要提交 lockfiles 到仓库?这就需要看项目定位决定了。

  • 如果开发一个应用,我建议把 package-lock.json 文件提交到代码版本仓库。这样可以保证项目组成员、运维部署成员或者 CI 系统,在执行 npm install 后,能得到完全一致的依赖安装内容。

  • 如果你的目标是开发一个给外部使用的库,那就要谨慎考虑了,因为库项目一般是被其他项目依赖的,在不使用 package-lock.json 的情况下,就可以复用主项目已经加载过的包,减少依赖重复和体积

  • 如果我们开发的库依赖了一个精确版本号的模块,那么提交 lockfiles 到仓库可能会造成同一个依赖不同版本都被下载的情况。如果作为库开发者,真的有使用某个特定版本依赖的需要,一个更好的方式是定义 peerDependencies

因此,一个推荐的做法是:把 package-lock.json 一起提交到代码库中,不需要 ignore。但是执行 npm publish 命令,发布一个库的时候,它应该被忽略而不是直接发布出去

理解上述概念并不够,对于 lockfiles 的处理,你需要更加精细。这里我列出几条建议供你参考。

  1. 早期 npm 锁定版本的方式是使用 npm-shrinkwrap.json,它与 package-lock.json 不同点在于:npm 包发布的时候默认将 npm-shrinkwrap.json 发布,因此类库或者组件需要慎重。

  2. 使用 package-lock.json 是 npm v5.x 版本新增特性,而 npm v5.6 以上才逐步稳定,在 5.0 - 5.6 中间,对 package-lock.json 的处理逻辑进行过几次更新。

  3. 在 npm v5.0.x 版本中,npm install 时都会根据 package-lock.json 文件下载,不管 package.json 内容究竟是什么。

  4. npm v5.1.0 版本到 npm v5.4.2,npm install 会无视 package-lock.json 文件,会去下载最新的 npm 包并且更新 package-lock.json。

  5. npm 5.4.2 版本后:

  • 如果项目中只有 package.json 文件,npm install 之后,会根据它生成一个 package-lock.json 文件;

  • 如果项目中存在 package.json 和 package-lock.json 文件,同时 package.json 的 semver-range 版本 和 package-lock.json 中版本兼容,即使此时有新的适用版本,npm install 还是会根据 package-lock.json 下载;

  • 如果项目中存在 package.json 和 package-lock.json 文件,同时 package.json 的 semver-range 版本和 package-lock.json 中版本不兼容,npm install 时 package-lock.json 将会更新到兼容 package.json 的版本;

  • 如果 package-lock.json 和 npm-shrinkwrap.json 同时存在于项目根目录,package-lock.json 将会被忽略。

以上内容你可以结合 01 讲中 npm 安装流程进一步理解。

为什么有 xxxDependencies?

npm 设计了以下几种依赖类型声明:

  • dependencies 项目依赖

  • devDependencies 开发依赖

  • peerDependencies 同版本依赖

  • bundledDependencies 捆绑依赖

  • optionalDependencies 可选依赖

它们起到的作用和声明意义各不相同。dependencies 表示项目依赖,这些依赖都会成为线上生产环境中的代码组成部分。当它关联的 npm 包被下载时,dependencies 下的模块也会作为依赖,一起被下载

devDependencies 表示开发依赖,不会被自动下载,因为 devDependencies 一般只在开发阶段起作用或只是在开发环境中需要用到。比如 Webpack,预处理器 babel-loader、scss-loader,测试工具 E2E、Chai 等,这些都是辅助开发的工具包,无须在生产环境使用。

这里需要特别说明的是:并不是只有在 dependencies 中的模块才会被一起打包,而在 devDependencies 中的依赖一定不会被打包。实际上,依赖是否被打包,完全取决于项目里是否被引入了该模块。dependencies 和 devDependencies 在业务中更多的只是一个规范作用,我们自己的应用项目中,使用 npm install 命令安装依赖时,dependencies 和 devDependencies 内容都会被下载。

peerDependencies 表示同版本依赖,简单来说就是:如果你安装我,那么你最好也安装我对应的依赖。举个例子,假设 react-ui@1.2.2 只提供一套基于 React 的 UI 组件库,它需要宿主环境提供指定的 React 版本来搭配使用,因此我们需要在 React-ui 的 package.json 中配置:

"peerDependencies": {"React": "^17.0.0"
}

举一个场景实例,对于插件类 (Plugin) 项目,比如我开发一个 Koa 中间件,很明显这类插件或组件脱离(Koa)本体是不能单独运行且毫无意义的,但是这类插件又无须声明对本体(Koa)的依赖声明,更好的方式是使用宿主项目中的本体(Koa)依赖。这就是peerDependencies 主要的使用场景。这类场景有以下特点:

  • 插件不能单独运行

  • 插件正确运行的前提是核心依赖库必须先下载安装

  • 我们不希望核心依赖库被重复下载

  • 插件 API 的设计必须要符合核心依赖库的插件编写规范

  • 在项目中,同一插件体系下,核心依赖库版本最好相同

bundledDependencies 和 npm pack 打包命令有关。假设 package.json 中有如下配置:

{"name": "test","version": "1.0.0","dependencies": {"dep": "^0.0.2",...},"devDependencies": {..."devD1": "^1.0.0"},"bundledDependencies": ["bundleD1","bundleD2"]
}

在执行 npm pack 时,就会产出一个 test-1.0.0.tgz 压缩包,且该压缩包中包含了 bundle D1 和 bundle D2 两个安装包。业务方使用 npm install test-1.0.0.tgz 命令时,也会安装 bundle D1 和 bundle D2。

这里你需要注意的是:在 bundledDependencies 中指定的依赖包,必须先在 dependencies 和 devDependencies 声明过,否则在 npm pack 阶段会进行报错

optionalDependencies 表示可选依赖,就是说即使对应依赖项安装失败了,也不会影响整个安装过程。一般我们很少使用到它,这里我也不建议大家使用,因为它大概率会增加项目的不确定性和复杂性

学习了以上内容,现在你已经知道 npm 规范中的相关依赖声明含义了,接下来我们再谈谈版本规范,帮助你进一步解析依赖库锁版本行为。

再谈版本规范——依赖库锁版本行为解析

npm 遵循 SemVer 版本规范,具体内容你可以参考语义化版本 2.0.0,这里不再展开。这部分内容我希望聚焦到工程建设的一个细节点上——依赖库锁版本行为。

Vue 官方有这样的内容:

每个 vue 包的新版本发布时,一个相应版本的 vue-template-compiler 也会随之发布。编译器的版本必须和基本的 vue 包保持同步,这样 vue-loader 就会生成兼容运行时的代码。这意味着你每次升级项目中的 vue 包时,也应该匹配升级 vue-template-compiler。

据此,我们需要考虑的是:作为库开发者,如何保证依赖包之间的强制最低版本要求?

我们先看看 create-react-app 的做法,在 create-react-app 的核心 react-script 当中,它利用 verify PackageTree 方法,对业务项目中的依赖进行比对和限制。源码如下:

function verifyPackageTree() {const depsToCheck = ['babel-eslint','babel-jest','babel-loader','eslint','jest','webpack','webpack-dev-server',];const getSemverRegex = () =>/\bv?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?\b/gi;const ownPackageJson = require('../../package.json');const expectedVersionsByDep = {};depsToCheck.forEach(dep => {const expectedVersion = ownPackageJson.dependencies[dep];if (!expectedVersion) {throw new Error('This dependency list is outdated, fix it.');}if (!getSemverRegex().test(expectedVersion)) {throw new Error(`The ${dep} package should be pinned, instead got version ${expectedVersion}.`);}expectedVersionsByDep[dep] = expectedVersion;});

let currentDir = __dirname;

while (true) {
const previousDir = currentDir;
currentDir = path.resolve(currentDir, ‘…’);
if (currentDir === previousDir) {
// We’ve reached the root.
break;
}
const maybeNodeModules = path.resolve(currentDir, ‘node_modules’);
if (!fs.existsSync(maybeNodeModules)) {
continue;
}
depsToCheck.forEach(dep => {
const maybeDep = path.resolve(maybeNodeModules, dep);
if (!fs.existsSync(maybeDep)) {
return;
}
const maybeDepPackageJson = path.resolve(maybeDep, ‘package.json’);
if (!fs.existsSync(maybeDepPackageJson)) {
return;
}
const depPackageJson = JSON.parse(
fs.readFileSync(maybeDepPackageJson, ‘utf8’)
);
const expectedVersion = expectedVersionsByDep[dep];
if (!semver.satisfies(depPackageJson.version, expectedVersion)) {
console.error(//…);
process.exit(1);
}
});
}
}

根据上述代码,我们不难发现,create-react-app 会对项目中的 babel-eslint、babel-jest、babel-loader、ESLint、Jest、webpack、webpack-dev-server 这些核心依赖进行检索——是否符合 create-react-app 对这些核心依赖的版本要求。如果不符合依赖版本要求,那么 create-react-app 的构建过程会直接报错并退出

create-react-app 这么做的理由是:需要上述依赖项的某些确定版本,以保障 create-react-app 源码的相关功能稳定

我认为这样做看似强硬且无理由,实则是对前端社区、npm 版本混乱现象的一种妥协。这种妥协确实能保证 create-react-app 的正常构建工作。因此现阶段来看,也不失为一种值得推荐的做法。而作为 create-react-app 的使用者,我们依然可以通过 SKIP_PREFLIGHT_CHECK 这个环境变量,跳过核心依赖版本检查,对应源码:

const verifyPackageTree = require('./utils/verifyPackageTree');
if (process.env.SKIP_PREFLIGHT_CHECK !== 'true') {verifyPackageTree();
}

create-react-app 的锁版本行为无疑彰显了目前前端社区中工程依赖问题的方方面面,从这个细节管中窥豹,希望能引起你更深入的思考。

最佳实操建议

前面我们讲了很多 npm 的原理和设计理念,理解了这些内容,你应该能总结出一个适用于团队的最佳实操建议。对于实操我有以下想法,供你参考。

  1. 优先使用 npm v5.4.2 以上的 npm 版本,以保证 npm 的最基本先进性和稳定性。

  2. 项目的第一次搭建使用 npm install 安装依赖包,并提交 package.json、package-lock.json,而不提交 node_modules 目录。

  3. 其他项目成员首次 checkout/clone 项目代码后,执行一次 npm install 安装依赖包。

  4. 对于升级依赖包的需求:

  • 依靠 npm update 命令升级到新的小版本;

  • 依靠 npm install @ 升级大版本;

  • 也可以手动修改 package.json 中版本号,并执行 npm install 来升级版本;

  • 本地验证升级后新版本无问题,提交新的 package.json、package-lock.json 文件。

  1. 对于降级依赖包的需求:执行 npm install @ 命令,验证没问题后,提交新的 package.json、package-lock.json 文件。

  2. 删除某些依赖:

  • 执行 npm uninstall 命令,验证没问题后,提交新的 package.json、package-lock.json 文件;

  • 或者手动操作 package.json,删除依赖,执行 npm install 命令,验证没问题后,提交新的 package.json、package-lock.json 文件。

  1. 任何团队成员提交 package.json、package-lock.json 更新后,其他成员应该拉取代码后,执行 npm install 更新依赖。

  2. 任何时候都不要修改 package-lock.json。

  3. 如果 package-lock.json 出现冲突或问题,建议将本地的 package-lock.json 文件删除,引入远程的 package-lock.json 文件和 package.json,再执行 npm install 命令。

如果以上建议你都能理解,并能够解释其中缘由,那么这三讲内容,你已经大致掌握了。

总结

通过本讲学习,相信你已经掌握了在 CI 环境中优化包管理器的方法以及更多、更全面的 npm 设计规范。希望不管是在本地开发,还是 CI 环境中,你在面对包管理方面的问题时能够游刃有余,轻松面对。

随着前端的发展,npm/Yarn 也在互相借鉴,不断改进,比如 npm v7 会带来一流的 Monorepo 支持。历史总是螺旋式前进,其间可能出现困局和曲折,但是对前端从业人员来说,时刻保持对工程化理念的学习,抽丝剥茧、理清概念,必能从中受益。

npm/Yarn 相关的话题不是一个独立的点,它是成体系的一个面,甚至可以算得上是一个完整的生态。这部分知识我们虽没有面面俱到,但是聚焦在依赖管理、安装机制、CI 提效等话题上。更多 npm 的内容,比如 npm scripts、公共库相关设计、npm 发包、npm 安全、package.json 等话题我会在后面章节中也会继续讲解,希望你能坚持学习。

不管是本地开发环境还是 CI 环境,不管是使用 npm 还是 Yarn,都离不开构建工具。下一讲我会带你对比主流构建工具,继续深入工程化和基建的深水区。我们下一讲再见。

前端架构设计第一课 CI环境npm/Yarn相关推荐

  1. 前端架构设计第十课 前端数据结构和算法

    21 如何利用 JavaScript 实现经典数据结构? 前面几讲我们从编程思维的角度分析了软件设计哲学.从这一讲开始,我们将深入数据结构这个话题. 数据结构是计算机中组织和存储数据的特定方式,它的目 ...

  2. 前端进阶之路: 前端架构设计(2)-流程核心

    可能很多人和我一样, 首次听到"前端架构"这个词, 第一反应是: "前端还有架构这一说呢?" 在后端开发领域, 系统规划和可扩展性非常关键, 因此架构师备受重视 ...

  3. 前端架构设计1:代码核心

    现在的前端领域, 随着JS框架, UI框架和各种库的丰富, 前端架构也变得十分的重要. 如果一个大型项目没有合理的前端架构设计, 那么前端代码可能因为不同的开发人员随意的引入各种库和UI框架, 导致代 ...

  4. 走向ASP.NET架构设计--第一章:走向设计

    走向ASP.NET架构设计--第一章:走向设计 前言:很多做开发的人都在不断的摸索着,积极的学习,试图找出一条走向架构设计的成功法则.每当有人问起我们的职业,我们也常常在说:"软件设计&qu ...

  5. 浅谈京东静态html原理,京东首页前端架构设计.ppt

    京东首页前端架构设计 工程化 Windows可视化工具 * 工程化 前端模块构建平台 * 总结 * QA * * JD.com JD.com JD.com JD.com JD.com JD.com J ...

  6. 前端架构设计应该包含哪些东西?

    前端架构设计 后台架构设计概念适用于前端,前端没有数据库设计,所以可以不考虑并发. vuejs的优点,一样适用于前端项目.高内聚,低耦合,可复用,单元测试. 从项目的生命周期,开发.上线.维护三个阶段 ...

  7. 架构方面学习笔记(3)-前端架构设计

    2022.02.08 今天读了一篇关于前端整洁架构的设计,因此对其中的内容进行了一些整理以及我自己的思考,后续阅读<领域驱动设计>后可以加入更多的内容. References: 前端领域的 ...

  8. 前端架构设计第十一课 自动化构建部署和工具

    23 npm cript:打造一体化的构建和部署流程 之前我们提到过,一个顺畅的基建流程离不开 npm scripts.npm scripts 将工程化的各个环节串联起来,相信任何一个现代化的项目都有 ...

  9. 前端架构设计第六课工程化构建、编译、运行

    12 如何理解 AST 实现和编译原理? 经常留意前端开发技术的同学一定对 AST 技术不陌生.AST 技术是现代化前端基建和工程化建设的基石:Babel.Webpack.ESLint.代码压缩工具等 ...

最新文章

  1. 计算机书籍- 聊天机器人技术原理与应用
  2. Machine Learning | (6) Scikit-learn的分类器算法-性能评估
  3. 深入浅出浏览器渲染原理
  4. 【caffe】使用draw_net绘制net结构
  5. NYOJ 716 River Crossing(动态规划)
  6. 对‘TIFFReadDirectory@LIBTIFF_4.0’未定义的引用
  7. JavaFX移动应用程序最佳实践,第1部分
  8. Educational Codeforces Round 33 (Rated for Div. 2) E. Counting Arrays
  9. 【微软官方文档】Windows终端(Windows Terminal)
  10. Django使用本机IP无法访问,使用127.0.0.1能正常访问
  11. Docker教程:docker远程repository和自建本地registry
  12. 可以下载solidworks2007 完整版的连接
  13. VRCORE开发者大赛圆满落幕,中国虚拟现实内容大集结
  14. for while循环练习
  15. 焦距換算倍率(Focal length ratio)
  16. 自动化测试中的反省与总结
  17. Java day23
  18. 转 Vmware虚拟机怎样实现与主机同时上网的方法。
  19. 《Web性能测试实战》性能测试用例模板
  20. 图像去模糊(维纳滤波)

热门文章

  1. 二手车行业等待Shopify
  2. 科大讯飞车辆贷款违约
  3. java页码_Java 添加页码到PDF文档
  4. 用sql dts导入mysql数据库,使用命令行导入sql文件到mysql数据库
  5. 低压铸造水模拟计算机控制系统,低压铸造中充型过程水模拟的研究.doc
  6. 教你如何更好的进行网页布局
  7. Pyspark聚类--GaussianMixture
  8. eplan部件列表手动修改_安利一个EPLAN的批量修改功能:外部编辑属性
  9. 孝心至上自强不息南阳何志强感动济源
  10. 向何志强学习什么(母校学报)