最近需要将工作中的一个TS包拆出一部分代码,以便在多个团队和项目中共享。原以为这会是一项特别简单的工作,但是也花了两天才大致拆成功。因此记录一下,也给有类似需求的同学一点经验。

所拆项目的大致功能:整个项目的结构大致分为:

  1. 一个基类和多个实现类,我们需要拆出一个实现类到包里,因此基类也得放到这个包里
  2. 一个代码生成工具,会读取目录下的所有配置文件并生成ts代码,这个工具也得放到包里

我们希望拆完之后的项目满足这些条件:

  1. 拆出的包(以下称子包或子项目)可以独立发布,方便外部用户使用
  2. 为了快速验证和减少开发过程中的额外步骤,原项目(以下也可能称母项目)可以本地引用子项目,而不用每次有修改都先提一个PR发布新版本,再通过升级版本号引用最新的改动

总而言之,就是我们虽然对外发布了这个包,且外部会通过包名+版本号来引用,但是团队内部项目开发时还是希望能通过本地引用直接引用到最新的改变。下面总结一下如何引用本地包和拆包后的代码需要注意什么。

小心缓存带来的编译、包导入不生效问题

由于接下来需要经常修改子包的配置,所以要特别注意缓存带来的问题,这样如果遇到奇怪的问题还能有印象是缓存带来的问题。

虽然TS老手可能已经知道缓存的坑,但是作为新手还是很容易被缓存导致的问题搞得很迷惑。如果遇到奇怪的问题,比如

  1. 敲了tsc --build却没有生成编译后的文件
  2. 有的ts编译生成了.d.ts,有的却没有,但是.js文件都存在,造成子项目引用时找不到类型
  3. 删除node_modules,再跑yarn install也不会安装依赖
  4. 子项目没有升版本就打包,母项目引用时还是安装的没有修改之前的版本

这三个都是我在拆子包的开发过程中遇到的问题,经常让我百思不得其解,还以为是自己改了什么配置改错了,但是改回来之后还是不工作。

其中1&2都是由于ts编译缓存造成的,缓存文件的文件名叫tsconfig.tsbuildinfo,它会记录最近一次编译,用于支持ts的增量编译功能。根据配置的不同(是否开启 composite=true),可能生成在项目根目录或者是构建输出目录下。如果删除了整个构建输出目录(比如下文我们会配置/lib为输出目录)但是没有清除缓存,那重新跑构建命令,也不会生成构建目录!因为ts编译器是通过对比源文件和增量编译缓存文件的差别来决定是否要重新编译的,而如果擅自删除了输出目录,缓存文件和输出文件很就存在不同步的情况,这就是为什么重新构建可能不生效的原因!

3&4的问题是因为我们的项目中使用了yarn,而npm/yarn会有缓存。比如3#就是要删除yarn.lock/package-lock.json文件后再yarn install

而4#最简单的办法是本地引用包时,一旦子包修改,就升一个版本再打包,等要push代码的时候再改回原来的版本。比如在package.json里增加这些scripts

"scripts": {"build": "tsc --build","clean": "rimraf lib && rimraf tsconfig.tsbuildinfo","rebuild": "yarn clean && yarn build","package": "yarn rebuild && yarn pack","local-pack": "npm version prerelease && yarn package"},

这样当你执行yarn rebuild或者yarn local-pack时,会自动清理lib目录和编译缓存;测试打包时也会自动升一个版本,避免出错。

引用本地包的方法

在介绍拆包需要怎么改代码之前,我们先说怎么本地引用包,这是因为如果不知道拆完的包该怎么被本地引用,那根本没法编译原来的项目,更别提怎么测试子项目功能是否正常了。

通过查找多种资料(包括问AI),大概有以下几种引用本地包的方法:

  1. tsconfig.ts中配置包的本地映射:在compilerOptions.paths中加上一行"{packageName}":["{pathToLocalPackage}"],个人感觉这种方法对于测试堪称完美,子项目的源码修改会立即反映到别的项目中。它的缺陷是没法很好地测到子包被发布出去之后,其它项目通过package.json引用时是否能正常工作,这是因为子项目中的代码都是源码级引用,随母项目的编译而编译,因此会掩盖一些问题,比如使用绝对路径(比如import xx from src/moduleA来导入模块可能并不会报错,但是通过package.json引用时就会找不到指定模块。
  2. 直接在package.json里使用引用本地项目的目录:使用yarn add或者直接修改package.json,添加"{packageName}":"file:{subPeojectFolderPath}"。这种方式也能实现源码级引用,但是它有一个很大的缺陷,file引用目录时,会将整个项目文件夹复制到node_modules内,导致子项目里的node_modules也会被原样拷贝过去。占用空间不说,它会导致子项目、母项目对同一包的引用出现冲突。AI还提供了一些使用peerDependencies的建议,实际上并没有用:它并非引用包的不同版本出现了冲突,而是子项目和母项目里的node_modules起了冲突!那可能有人会说,如果我子项目不安装依赖呢?如果子项目不安装依赖,那也没法编译,也会造成很多问题。所以实际上我最不推荐这种做法。
  3. 使用yarn link:先在子项目下执行yarn link,然后在母项目下执行yarn link "{packageName}"。这种方式和1#很类似,但是子包是通过编译后代码来引用的(去决定于子包里package.json如何配置),更能测试出一些引用问题。
  4. 先将子项目本地打包,再通过文件引用打包出的tar ball:先在子项目下执行yarn pack,然后在母项目下执行yarn add"{packageTarBallPath}"。这种方法是最能模拟子包发布之后,被其它项目引用的行为的,因为它可以测试我们的打包配置是否正确,比如后文提到的tsconfig.jsonpackage.json这两个配置文件里,错误的配置会导致包无法被正确引用。
  5. yarn workspace:创建一个workspace,将两个项目添加到workspace中。两个项目会共享一个node_modules依赖,又保持独立性,避免了2#中的问题,很优雅。缺点是可能需要改变项目的目录结构,需要用一个父目录来包含多个子项目。

综上,这些方法中不推荐2#,如果可以接受改变目录结构则5#看起来是最优雅的办法。

如果不能改变目录结构,那追求便捷度首推1#,因为修改可以立刻反映到母项目,IDE可以立刻检查出有没有语法错误,编译过程也很流畅,同时开发中也无需跑额外的命令来关联两个项目;如果按照与真实引用环境的差别排序,首推4#。剩下的3#有点鸡肋,因为它不是通过修改package.json来改变项目的引用关系,而是需要跑两个额外的命令临时关联两个项目,因此只适合临时测试。

我在自己的项目中,用的是方法4#。因为我们依赖的库如果使用方法1#会导致一些奇怪的错误,子项目变动也不会很大,因此我们主要考虑保证项目能正确运行。只不过这样每次如果需要修改并测试子项目,都要重新编译。实际开发可以考虑1#和4#结合的方法,先用1#保证编译通过,再用4#保证运行正确。

修改子项目

除了把代码都拷到另一个独立的项目之外,还有一些值得注意的点,主要是注意

  1. 配置好package.jsontsconfig.json,确保打包的代码能被正确引用
  2. 避免绝对路径代码,要使用位置无关性代码,确保子包中代码运行结果符合预期

tsconfig.json和package.json

我们需要修改tsconfig.json来保证编译后的代码会生成在正确的目录,同时为了保证发布的包可以被正确引用我们需要配置package.json,写明项目的入口文件。

tsconfg

  • compilerOptions.outDir指明了编译后的js/ts文件放在哪个目录下。比如我们配置成lib,那就会在lib文件夹下看到编译后的代码
  • compilerOptions.rootDir指明了源码文件的储存路径。编译后生成的文件会保持rootDir为根目录的目录结构。通常我们的源代码放在src目录下,如果不设置rootDir时,默认会以项目所在的目录为根目录,编译后的代码会放在lib/src目录下。当rootDir设置成了src,那生成的编译后代码就会放在lib下而不是lib/src下,使打包出的目录层级更清晰(否则保持src目录会让人很迷惑,一般src用于存放源代码而不是生成的代码)

package.json

以下配置需要匹配tsconfig.json的配置,如果不太确定,可以跑tsc编译看看生成的目录里对应的文件放在哪了。

  • types:指定了编译出的.d.ts文件。它是ts的类型声明文件,通常用于保证ts编译期类型系统正常工作
  • main:指定了编译出的.js文件入口。它包含了真正的代码实现(而编译后的.d.ts只包含类型声明,有点类似于接口与实现或者头文件和源文件的差别)

设置peerDependencies

通常我们在项目有三种和依赖相关的设定:

  • dependencies:项目的依赖,基本上可以认为代码里要import的包都需要加到这个依赖配置中
  • peerDependencies:指定了当该项目被消费方使用时,所使用的包版本。如果没有指定,则会使用dependencies中指定的版本(自己在使用yarn时验证了这个行为)
  • devDependencies:项目开发时需要,但是生产环境不需要的依赖,通常不是代码里import的包。例子包括一些命令行工具(比如可用于删除文件的rimraf包,通常用于在build之前清理生成文件),代码格式化工具等

我相信大家已经非常了解devDependencies是什么,但对peerDependencies可能不够了解。peerDependencies通常在库的package.json中被声明,它有两方面语义:

  1. 告诉消费这个包的项目,库需要运行在某些包的特定版本之上
  2. 对于peer里指定的包,库会“尽量”和主项目共享同一份副本。共享同一份副本意味着二者中的代码调用peer中的共同依赖时,会指向同一个本地路径,不会造成包依赖冲突(比如类型不兼容)的问题。

从2#中可以看出peerDependenciesdependencies最大区别:前者会尽量共享同一个包的副本,避免重复安装和依赖不兼容,这些问题在使用库时是很常见的问题;而后者则大多数情况下会重复安装,即使包的名称和版本都相同。现在只剩一个问题没有解决,什么叫“尽量”共享同一副本呢?根据包管理器的不同,大致如下的一些行为:

  • 如果peer版本和主项目兼容,则会共享同一个包,这一点在多个包管理工具中都是相同的
  • 如果peer版本和主项目不兼容,则可能有多种行为:
    • 继续使用主项目的包版本,可能会有包兼容性检查的警告
    • 如果主项目安装了满足peer版本的包,则主项目和库会运行在不同版本的依赖之上
    • 报错,依赖安装失败

也有一些配置项如peerDependenciesMeta会影响这些行为,但多数情况下都是使用包管理器的默认行为。这会造成一些令人迷惑的结果。比如我实际操作中,有一个依赖A的版本指定的是^0.20250101.1901000这样的版本,在yarn 22.x版本下,它使用的是让主项目和库运行在不同版本依赖的策略,因此即使主项目更新了依赖A的版本,主项目调用子包时,子包使用的还是子包指定的版本。

另外再说一个有趣的知识点,^在版本号中的含义是“第一个非零的版本号一致”。对于常规版本号,比如1.2.3,就意味着1.x.x,而对于0.2.3就意味着0.2.x了。我在项目中使用的依赖是以日期作为第一个非零主版本号,因此就会导致即使只把依赖更新到第二天的版本,yarn也会认为版本是不兼容的,转而让主项目和子包使用不一样的依赖版本,造成了这个隐蔽的问题。

测试

由于这两个配置文件主要影响子包中的代码能否被正确引用,所以通过在母项目中跑tsc --build编译就能看出配置是否正确。

本地模块import改为相对路径引用

本地模块导入指的是通过类似import {xx} from "../classA"这样的代码来导入本地某个模块。如果我们的包不需要给别人用,那我们完全可以写一个绝对路径,比如import {xx} from "src/folderA/classA"。但是另一个项目B引用时如果遇到绝对路径的导入,一般会以项目B的根目录作为根目录来查找这个模块,而子包里写的这句import则是以子包的根目录作为根目录,自然就会导致找不到模块的错误。

可以搜索一下子项目里有没有用到"src这样的关键字,用到的话记得改掉。

使用外部传入的路径来计算外部文件的真实路径

假如子包中有一些读取外部文件(非子包文件)的代码,比如读取某个目录下所有的配置文件,读取某个TS模块等,我们都要甄别这些路径,把读取的路径改为真实路径。比如曾经我们的代码在拆之前,可能相对于读取的目录有固定的相对路径比如__dirname/../anotherFolder/anotherModule,拆分后得让代码调用方把真实路径传进来,而不能还用之前基于当前模块路径计算的方法。从代码层面看,这些方法需要传的参数变多了。

可以搜索一下子项目里有没有用到__dirname__filename这样的关键字,用到的话记得改掉。

发布

确认好发布的包可以正常工作后,发布基本上是最简单的步骤了。通常工作中会使用独立的npm仓库,可以用如下命令发布某个目录下的包,或者打包好的tarball到指定仓库

yarn publish [<tarball>|<folder>] --registry <url>

常见问题

打出的包被别人引用,看到node_modules下也成功安装了库文件,但是用包名引用提示“找不到模块”

package.jsonmaintypes指定错了,要确保目录层级和包里package.json与指定文件的相对路径一致

主项目运行时,提示子包内的模块找不到(通常是require语句报错)

通常是因为子包使用了绝对路径而非相对路径

主项目更新了依赖,主项目中尝试直接调用依赖,用的是新版本依赖,但是子项目调用的还是旧版本的依赖

没有设置好peerDependencies,详见文章对应章节

修改了子项目的代码,打包后安装到主项目,发现主项目里并没有安装更新的包

通常是缓存问题,检查如下方面:

  1. 最好确保每次用npm version命令升一个版本再打包安装
  2. 生成目录和tgz包最好每次删掉,否则通常它们都只会增量更新

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/bicheng/95769.shtml
繁体地址,请注明出处:http://hk.pswp.cn/bicheng/95769.shtml
英文地址,请注明出处:http://en.pswp.cn/bicheng/95769.shtml

如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

无人机各种接头焊接方法

无人机接头的焊接直接关系到设备可靠性和飞行安全&#xff0c;以下是常见接头的焊接方法及注意事项&#xff1a;一、焊接通用原则工具准备恒温焊台&#xff08;推荐温度&#xff1a;$350 \pm 20^{\circ}\text{C}$&#xff09;含松芯焊锡丝&#xff08;直径0.8mm&#xff09;助焊…

[Linux] Linux标准块设备驱动详解:从原理到实现

Linux标准块设备驱动详解&#xff1a;从原理到实现 在Linux系统中&#xff0c;块设备是存储系统的核心组成部分&#xff0c;涵盖了硬盘、固态硬盘&#xff08;SSD&#xff09;、U盘、SD卡等各类持久化存储介质。与字符设备不同&#xff0c;块设备以固定大小的“块”为单位进行数…

什么是压力测试,有哪些方法

压力测试&#xff08;Stress Testing&#xff09;是性能测试的一种&#xff0c;旨在评估系统在极端负载条件下的表现&#xff0c;验证其稳定性、可靠性和容错能力。通过模拟超出正常范围的并发用户、数据量或请求频率&#xff0c;发现系统在高负载下的瓶颈&#xff08;如内存泄…

lua脚本在redis中执行是否是原子性?

lua脚本在redis中执行是否是原子性&#xff1f;以及是否会阻塞其他脚本的执行【客户端的请求】&#xff1f;先解答第二个问题:是的&#xff0c;保持原子执行。这也是redis中支持lua脚本执行的原因。Lua 脚本在 Redis 中是以原子方式执行的&#xff0c;在 Redis 服务器执行EVAL命…

DeepSeek文献太多太杂?一招制胜:学术论文检索的“核心公式”与提问艺术

如果我们想要完成一次学术论文检索&#xff0c;那我们可以把它想象成一次精准的“学术寻宝”。你不是在漫无目的地闲逛&#xff0c;而是一名装备精良的“学术寻宝猎人”&#xff0c;你的目标是找到深藏在浩瀚文献海洋中的“珍宝”&#xff08;高价值论文&#xff09;。1 你的寻…

Linux内存管理章节一:深入浅出Linux内存管理:从物理内存到ARM32的用户与内核空间

引言 如果说操作系统是计算机的心脏&#xff0c;那么内存管理就是它的灵魂脉络。它默默地工作在Linux内核的最底层&#xff0c;却决定着整个系统的稳定性、安全性和性能。今天&#xff0c;我们将拨开迷雾&#xff0c;深入探索Linux内存管理的核心概念&#xff0c;并结合熟悉的A…

ECMAScript (5)ES6前端开发核心:国际化与格式化、内存管理与性能

好的&#xff0c;我将根据【国际化与格式化】和【内存管理与性能】这两个主题&#xff0c;为你生成详细的课件内容&#xff0c;涵盖概念、应用和实例。 &#x1f4d7; 前端开发核心&#xff1a;国际化与格式化、内存管理与性能 1. 国际化与格式化 (Internationalization & …

3D 可视化数字孪生运维管理平台:构建 “虚实协同” 的智慧运维新范式

3D 可视化数字孪生运维管理平台通过 “物理空间数字化建模 实时数据动态映射 智能分析决策”&#xff0c;将建筑、园区、工业设施等物理实体 1:1 复刻为虚拟孪生体&#xff0c;打破传统运维 “信息割裂、依赖经验、响应滞后” 的痛点&#xff0c;实现从 “被动抢修” 到 “主…

DP-观察者模式代码详解

观察者模式&#xff1a; 定义一系列对象之间的一对多关系&#xff1b;当一个对象改变状态&#xff0c;它的依赖都会被通知。 主要由主题&#xff08;Subject&#xff09;和观察者&#xff08;Observer&#xff09;组成。 代码实现 package com.designpatterns.observer;/*** 定…

1983:ARPANET向互联网的转变

一、ARPANET早期1969年诞生的ARPANET最初还算不上互联网&#xff0c;不过在ARPANET构建之初就已经考虑了分组交换&#xff1a;1970年代的ARPANET:其实这个时候我就有疑问&#xff0c;TCP/IP是1983年1月1日更新到ARPANET的&#xff0c;但是1970年代的ARPANET已经连接全美的重要单…

自动化运维-ansible中的变量运用

自动化运维-ansible中的变量运用 一、变量命名规则 组成&#xff1a;字母、数字、下划线。必须以字母开头。 合法: app_port, web_1, varA非法: 2_var (以数字开头), my-var (包含其他字符), _private (以下划线开头) 避免使用内置关键字&#xff1a;例如 hosts, tasks, name…

深入学习并发编程中的volatile

volatile 的作用 保证变量的内存可见性禁止指令重排序1.保证此变量对所有的线程的可见性&#xff0c;当一个线程修改了这个变量的值&#xff0c;volatile 保证了新值能立即同步到主内存&#xff0c;其它线程每次使用前立即从主内存刷新。 但普通变量做不到这点&#xff0c;普通…

使用Java获取本地PDF文件并解析数据

获取本地文件夹下的PDF文件要获取本地文件夹下的PDF文件&#xff0c;可以使用Java的File类和FilenameFilter接口。以下是一个示例代码片段&#xff1a;import java.io.File; import java.io.FilenameFilter;public class PDFFileFinder {public static void main(String[] args…

吴恩达机器学习补充:决策树和随机森林

数据集&#xff1a;通过网盘分享的文件&#xff1a;sonar-all-data.csv 链接: https://pan.baidu.com/s/1D3vbcnd6j424iAwssYzDeQ?pwd12gr 提取码: 12gr 学习来源&#xff1a;https://github.com/cabin-w/MLBeginnerHub 文末有完整代码&#xff0c;由于这里的代码和之前的按…

Shell脚本一键监控平台到期时间并钉钉告警推送指定人

1. 监控需求客户侧有很多平台需要定期授权&#xff0c;授权后管理后台才可正常登录&#xff0c;为避免授权到期&#xff0c;现撰写脚本自动化监控平台授权到期时间&#xff0c;在到期前15天钉钉或其他媒介提醒。2. 监控方案2.1 收集平台信息梳理需要监控的平台地址信息&#xf…

华为HCIE数通含金量所剩无几?考试难度加大?

最近网上很火的一个梗——法拉利老了还是法拉利&#xff0c;这句话套在华为HCIE数通身上同样适用&#xff0c;华为认证中的华为数通和云计算两大巨头充斥着大家的视野里面&#xff0c;也更加广为人知&#xff0c;但随着时代的发展&#xff0c;华为认证体系的调整&#xff0c;大…

#数据结构----2.1线性表

在数据结构的学习中&#xff0c;线性表是最基础、最核心的结构之一 —— 它是后续栈、队列、链表等复杂结构的 “基石”。今天从 “是什么”&#xff08;定义&#xff09;到 “怎么用”&#xff08;基本操作&#xff09;&#xff0c;彻底搞懂线性表的核心逻辑。 一、先明确&…

2508C++,skia动画

gif动画原理 先了解一下gif动画的原理: gif动画由一系列静态图像(或叫帧)组成.这些图像按特定的顺序排列,每一帧都代表动画中的一个瞬间,帧图像是支持透明的. 每两帧之间有指定的时间间隔(一般小于60毫秒),gif播放器每渲染一帧静态图像后,即等待此时间间隔,依此逻辑不断循环渲染…

AI + 机器人:当大语言模型赋予机械 “思考能力”,未来工厂将迎来怎样变革?

一、引言1.1 未来工厂变革背景与趋势在科技飞速发展的当下&#xff0c;全球制造业正站在变革的十字路口。随着消费者需求日益多样化、市场竞争愈发激烈&#xff0c;传统工厂模式的弊端逐渐显现。生产效率低下、难以适应个性化定制需求、设备维护成本高昂且缺乏前瞻性等问题&…

pinia状态管理的作用和意义

1. 什么是状态管理 状态管理就是统一管理应用中的数据&#xff0c;让数据在多个组件之间共享和同步。 // 没有状态管理 - 数据分散在各个组件中 // 组件A const user ref({ name: 张三, age: 25 })// 组件B const user ref({ name: 张三, age: 25 }) // 重复定义// 组件C c…