关于前端:深入解读pnpm-patch-packages的底层实现实现自定义忽略文件或目录的方法探索

43次阅读

共计 9433 个字符,预计需要花费 24 分钟才能阅读完成。

概论

在本文中,咱们将深刻探讨 pnpm 的 patch-package 性能,并探讨如何实现自定义疏忽文件或目录的办法。咱们将回顾 patch-package 的原理,介绍 Git 中的 diff 命令以及它们在 patch commit 中的利用。而后,咱们将探讨文件或目录疏忽的重要性和利用场景,并探讨在 patch commit 中实现自定义疏忽的新办法。

在实现自定义疏忽文件或目录的办法方面,咱们将具体解说如何获取要打包的文件列表。

接下来,咱们将解释在改变和实现过程中所做的具体改变和代码逻辑,并剖析在 patch commit 中实现自定义疏忽性能的关键步骤。

最初,咱们将分享在实现过程中遇到的挑战和解决方案,并总结实现自定义疏忽性能对于技术能力晋升和常识拓展的影响。

通过本文的浏览,读者将对 pnpm 的 patch-package 性能有更深刻的理解。

引言

介绍 patch-package 的概念和作用

在前端开发中,咱们常常会遇到一些依赖包的问题,比方某个包有 bug,或者某个包的性能不合乎咱们的需要。这时候,咱们可能会想要批改这些包的源码,然而间接批改 node_modules 中的文件是不可取的,因为这样会导致依赖不稳固,而且每次装置或更新依赖都会笼罩咱们的批改。那么,有没有一种办法能够让咱们在不影响依赖治理的前提下,对某些包进行定制化的批改呢?答案是有的,那就是 patch-package。

patch-package 是一个 npm 包,它能够让咱们对 node_modules 中的任何包进行批改,并且将这些批改保留在一个 patch 文件中。这样,咱们就能够在我的项目中应用这个 patch 文件来笼罩原始的包,从而实现对依赖包的定制化。patch-package 的应用非常简单,只须要装置它,而后在 package.json 中增加一个 postinstall 脚本,就能够在每次装置或更新依赖后主动利用 patch 文件。然而遗憾的是,在应用 pnpm 的状况下,这个包无奈失常应用,然而 pnpm 官网新增了两个命令来解决这个问题:pnpm patch xxx@xxx (--edit-dir xxx)pnpm patch-commit (--edit-dir)

为什么须要实现自定义疏忽文件或目录的性能

pnpm patch-package 是一个十分实用和弱小的工具,它能够帮忙咱们解决很多依赖包的问题。然而,在应用它的过程中,我发现了一个问题:它没有提供一种办法来让咱们自定义疏忽某些文件或目录。这意味着,如果咱们对一个包进行了批改,然而只想保留其中一部分批改,而疏忽其余局部,那么咱们就无奈实现。比方 IDE 的配置文件、长期文件夹等。这样就会导致 patch 文件过大,而且可能会引入一些不必要或谬误的批改。

然而 pnpm v8.6.2 之前的版本的 patch-package 并没有这个性能,于是笔者在 pnpm 的 Git 仓库提了一个 issue:Issues · pnpm/pnpm (github.com)。
为了解决这个问题,我决定摸索一种新的办法来实现自定义疏忽文件或目录的性能。在本文中,我将具体介绍我所做的尝试和实现过程,并分享我在这个过程中的心得和播种。

patch-package 的原理回顾

在介绍我的办法之前,让咱们先回顾一下 patch-package 的基本原理和流程。patch-package 的核心思想是利用 Git 中的 diff 命令来生成和利用 patch 文件。diff 命令能够比拟两个文件或目录之间的差别,并以一种特定的格局输入后果。这种格局被称为 unified diff format(对立差别格局),它能够用来形容两个版本之间的变动。例如:

diff
--- a/file1.txt
+++ b/file1.txt
@@ -1,3 +1,4 @@
+This is an important line
This is the original file
-This is a line to delete
+This is a modified line
This is another line

下面的例子展现了 file1.txt 文件从 a 版本到 b 版本之间的变动。其中:

  • ---+++ 示意两个版本的文件名。
  • @@示意变动产生的地位和范畴。
  • +示意新增的行。
  • -示意删除的行。

有了这种格局,咱们就能够用 Git 中的 apply 命令来将这些变动利用到另一个文件或目录上。例如:

git apply file1.patch

下面的命令会将 file1.patch 文件中的变动利用到当前目录下的 file1.txt 文件上。

patch-package 就是利用了这种机制来实现对依赖包的批改。它的工作流程大抵如下:

  1. 在我的项目中装置或更新依赖包。
  2. 在 node_modules 中找到要批改的包,并对其进行批改。
  3. 运行 npx patch-package <package-name> 命令,生成一个 patch 文件,并保留在 patches 目录下。
  4. 在 package.json 中增加一个 postinstall 脚本,如"postinstall": "patch-package"
  5. 在我的项目中应用 patch 文件笼罩原始的包。

这样,每次装置或更新依赖后,patch-package 就会主动利用 patch 文件,从而实现对依赖包的定制化。

剖析需要和实现思路

在摸索如何实现自定义疏忽文件或目录的性能之前,咱们须要先剖析一下这个需要的实现思路。最直观的想法是,咱们须要一个配置文件来指定哪些文件或目录须要疏忽。而后,在利用 patch 文件时,咱们能够读取这个配置文件,并在利用补丁之前先疏忽这些文件或目录。

当初咱们须要思考的就是如何实现这个配置文件。一个简略的办法是应用.gitignore 配置文件,其中蕴含一个列表,列出了咱们要疏忽的文件或目录。然而,这样子看上去并不是很优雅和灵便,因为.gitignore 配置文件次要是为 git 版本控制服务而设计的,而不是为补丁工具服务。在 pnpm 作者给出的倡议下,咱们能够利用现有的设置来指定哪些文件在打包时须要蕴含。这就须要应用 package.json 文件中的 files 字段。

咱们后面提到,patch-package 的核心思想是利用 Git 中的 diff 命令来生成和利用 patch 文件,所以剖析得出,想要实现自定义疏忽文件或目录的性能,咱们须要在 patchd 操作的 diff 局部的代码中进行批改。具体来说,咱们须要在进行 diff 操作之前先将须要疏忽的文件过滤掉,或者在进行 diff 操作时对这些文件进行过滤。这样就能够确保在生成 patch 文件时排除这些文件,从而达到疏忽文件或目录的目标。

package.json 文件中的 files 字段。

当咱们在编写 npm 包时,能够在 package.json 文件中增加一个名为 files 的字段,用来指定 蕴含 在 npm 包中的文件和目录。这个字段的值是一个数组,其中列出的文件或目录将被蕴含在 npm 包中,而其余文件和目录则会被疏忽。

例如,上面是一个简略的 package.json 文件,其中蕴含了 files 字段:

{
  "name": "my-package",
  "version": "1.0.0",
  "description": "My awesome package",
  "files": [
    "src",
    "index.js",
    "LICENSE"
  ]
}

下面的例子中,咱们列出了包中蕴含的文件和目录,包含 src 目录、index.js 文件和 LICENSE 文件。这意味着,当咱们公布这个包时,只有这些文件会被蕴含在 npm 包中。

介绍 Git 中的 diff 命令以及它们在 pnpm patch commit 中的利用

有了后面的剖析,我很快就找到了 patchd 操作的 diff 局部, 其外围源码如下所示

async function diffFolders (folderA: string, folderB: string) {const folderAN = folderA.replace(/\\/g, '/')
  const folderBN = folderB.replace(/\\/g, '/')
  let stdout!: string
  let stderr!: string

  try {const result = await execa('git', ['-c', 'core.safecrlf=false', 'diff', '--src-prefix=a/', '--dst-prefix=b/', '--ignore-cr-at-eol', '--irreversible-delete', '--full-index', '--no-index', '--text', folderAN, folderBN], {cwd: process.cwd(),
      env: {
        ...process.env,
        GIT_CONFIG_NOSYSTEM: '1',
        HOME: '',
        XDG_CONFIG_HOME: '',
        USERPROFILE: '',
        // #endregion
      },
    })
    stdout = result.stdout
    stderr = result.stderr
  } catch (err: any) {
    stdout = err.stdout
    stderr = err.stderr
  }
  // we cannot rely on exit code, because --no-index implies --exit-code
  // i.e. git diff will exit with 1 if there were differences
  if (stderr.length > 0)
    throw new Error(`Unable to diff directories. Make sure you have a recent version of 'git' available in PATH.\nThe following error was reported by 'git':\n${stderr}`)

  return stdout
    .replace(new RegExp(`(a|b)(${escapeStringRegexp(`/${removeTrailingAndLeadingSlash(folderAN)}/`)})`, 'g'), '$1/')
    .replace(new RegExp(`(a|b)${escapeStringRegexp(`/${removeTrailingAndLeadingSlash(folderBN)}/`)}`, 'g'), '$1/')
    .replace(new RegExp(escapeStringRegexp(`${folderAN}/`), 'g'), '')
    .replace(new RegExp(escapeStringRegexp(`${folderBN}/`), 'g'), '')
    .replace(/\n\\ No newline at end of file$/, '')
}

在上述代码中,有一个应用了 git diff 命令的函数diffFolders,它用于比拟两个文件夹之间的差别,并返回一个 patch 文件的内容。

diffFolders 函数中,咱们应用了 git diff 命令来比拟两个文件夹之间的差别。其中,--src-prefix=a/--dst-prefix=b/ 选项指定了差别内容的前缀,--ignore-cr-at-eol选项用于疏忽行结束符的不同,--irreversible-delete选项用于禁止逆向删除(即文件从 B 目录挪动到 A 目录)的操作,--full-index选项用于生成残缺的 Git 索引,--no-index选项用于指定不应用 Git 索引进行比拟,--text选项用于指定比拟的内容为文本文件。最初,通过正则表达式的替换操作,将差别内容中的前缀和文件门路进行了解决,去除了不必要的前缀信息。

pnpm patch commit命令用于提交由 pnpm patch 生成的 patch 文件,它能够接管一个文件夹门路作为参数,示意该文件夹中蕴含一个或多个由 pnpm patch 生成的 patch 文件。而 pnpm patch commit 命令就是利用了 diffFolders 函数帮忙咱们生成将要 patch 文件的内容。

实现自定义疏忽文件或目录的办法

难题就差如何过滤掉配置的文件,而留下须要 diff 的文件列表了。我开始着手在 patch-package 中实现自定义疏忽文件或目录性能。

计划一:手写疏忽文件的逻辑

最后的想法是手写疏忽文件的逻辑。在利用补丁前,读取 package.json 中的 ”files” 字段,而后遍历须要打补丁的文件,将除了在 ”files” 字段中列出的文件或目录进行疏忽。这个实现起来比较简单,但须要用到一些 node.js 内置模块,如 fs、path 和 glob。

首先,咱们须要读取 package.json 中的 ”files” 字段到一个数组中,接下来,咱们遍历须要打补丁的文件,应用 node.js 的 glob 模块来查找包含子文件夹在内的所有文件。而后,再将文件门路进行拆分,与文件列表中的文件或目录进行比拟,如果门路与疏忽列表中的文件或目录匹配,则将该文件从解决列表中拷贝到一个长期文件夹。这样子实现的成果是能够过滤掉须要疏忽的文件或目录,然而这个实现很不完满,会漏掉一些文件,也会蕴含一些不必要的文件,还存在一些潜在的 Bug。这是因为手写过滤代码可能存在很多细节问题,而且也不够灵便。

计划二:应用 npm-packlist

为了解决手写过滤代码会存在的问题,我开始寻找其余解决方案。最终我找到了一个名为 npm-packlist 的 npm 包

npm-packlist 包的介绍和应用和劣势

npm-packlist 是一个 npm 的工具包,它能够帮忙咱们在打包一个 npm 包的时候,主动获取要增加到包中的文件列表。这样咱们就不必手动指定哪些文件是须要打包的,哪些文件是能够疏忽的,从而进步了打包的效率和准确性。

npm-packlist 的劣势在于它遵循了一些规定,来主动过滤掉一些不必要或者敏感的文件,比方:

  • 如果有 package.json 文件,并且有 files 字段,那么只打包 files 字段指定的文件。
  • 总是打包 readme, license, licence, copying, notice 和 package.json 文件,如果它们存在的话。
  • 如果没有 package.json 文件或者没有 files 字段,并且有.npmignore 文件,那么疏忽.npmignore 文件指定的文件。
  • 如果没有 package.json 文件或者没有 files 字段,并且没有.npmignore 文件,然而有.gitignore 文件,那么疏忽.gitignore 文件指定的文件。
  • 疏忽根目录下的 node_modules 文件夹,除非它是一个捆绑依赖。
  • 疏忽一些常见的无用或者危险的文件,比方.npmrc, .gitignore, .npmignore, .DS_Store, npm-debug.log 等。

利用 npm-packlist 包获取要打包的文件列表之后的实现

总的来说,实现自定义疏忽文件或目录的性能能够基于 npm-packlist 包实现。具体步骤如下:

  1. 读取 package.json 中的 ”files” 字段
  2. 应用 npm-packlist 包过滤须要打补丁的文件和目录。
  3. 将 npm-packlist 包过滤的后果拷贝到长期文件夹。
  4. 间接将长期文件夹与源码目录进行 diff 比拟。
  5. 完活了

这种实现办法能够很好地防止手写过滤代码时存在的问题,不仅更加灵便,而且更加易于保护和扩大

我的实现代码如下:

/**
 * pnpm patch commit 解决装置命令的处理程序
 * @param opts 装置命令的选项,包含 install.InstallCommandOptions 和 Config 的局部属性
 * @param params 参数列表,蕴含用户目录门路
 * @returns Promise,安装操作的后果
 */
export async function handler(
  opts: install.InstallCommandOptions & Pick<Config, 'patchesDir' | 'rootProjectManifest'>,
  params: string[]) {
  // 获取用户变更代码目录,也就是 pnpm patch commit 命令的第一个参数
  const userDir = params[0]
  // 获取 lockfile 文件目录
  const lockfileDir = opts.lockfileDir ?? opts.dir ?? process.cwd()
  // 获取补丁目录名称
  const patchesDirName = normalizePath(path.normalize(opts.patchesDir ?? 'patches'))
  // 拼接补丁目录门路
  const patchesDir = path.join(lockfileDir, patchesDirName)
  // 创立补丁目录
  await fs.promises.mkdir(patchesDir, { recursive: true})
  // 从用户目录中读取补丁后的 package.json 文件
  const patchedPkgManifest = await readPackageJsonFromDir(userDir)
  // 获取补丁后的包名称和版本号
  const pkgNameAndVersion = `${patchedPkgManifest.name}@${patchedPkgManifest.version}`
  // 创立长期目录
  const srcDir = tempy.directory()
  // 将补丁后的包写入长期目录
  await writePackage(parseWantedDependency(pkgNameAndVersion), srcDir, opts)

  // 过滤并复制文件到长期目录
  const filteredFolder = await filterAndCopyFiles(userDir, tempy.directory())
  // 获取补丁内容
  const patchContent = await diffFolders(srcDir, filteredFolder)

  // 依据包名称和版本号生成补丁文件名
  const patchFileName = pkgNameAndVersion.replace('/', '__')
  // 将补丁内容写入补丁文件
  await fs.promises.writeFile(path.join(patchesDir, `${patchFileName}.patch`), patchContent, 'utf8')
  
  // 尝试读取我的项目清单文件和根我的项目 package.json 文件
  const {writeProjectManifest, manifest} = await tryReadProjectManifest(lockfileDir)
  const rootProjectManifest = opts.rootProjectManifest ?? manifest ?? {}

  // 如果我的项目清单中没有 pnpm 属性,则创立该属性
  if (!rootProjectManifest.pnpm) {
    rootProjectManifest.pnpm = {patchedDependencies: {},
    }
  }
  // 如果我的项目清单中的 pnpm 属性没有 patchedDependencies 属性,则创立该属性
  else if (!rootProjectManifest.pnpm.patchedDependencies) {rootProjectManifest.pnpm.patchedDependencies = {}
  }
  // 将补丁门路增加到根我的项目清单的 patchedDependencies 中
  rootProjectManifest.pnpm.patchedDependencies![pkgNameAndVersion] = `${patchesDirName}/${patchFileName}.patch`
  // 将更新后的根我的项目清单写入文件
  await writeProjectManifest(rootProjectManifest)

  // 如果存在选定的我的项目图谱,并且锁定文件目录存在于图谱中,则更新对应我的项目的清单
  if (opts?.selectedProjectsGraph?.[lockfileDir]) {opts.selectedProjectsGraph[lockfileDir].package.manifest = rootProjectManifest
  }

  // 如果存在所有我的项目的图谱,并且锁定文件目录存在于图谱中,则更新对应我的项目的清单
  if (opts?.allProjectsGraph?.[lockfileDir].package.manifest) {opts.allProjectsGraph[lockfileDir].package.manifest = rootProjectManifest
  }

  // 调用 install.handler() 实现安装操作
  return install.handler({
    ...opts,
    rawLocalConfig: {
      ...opts.rawLocalConfig,
      'frozen-lockfile': false,
    },
  })
}

async function filterAndCopyFiles(source: string, destination: string) {
  // 利用 packlist 获取源目录下的所有文件列表
  const files = await packlist({path: source})
  // 如果文件列表为空,则返回源目录
  if (files.length === 0) {return source}
  // 复制文件到目标目录
  await Promise.all(files.map(async (file) => {const sourcePath = path.join(source, file)
      const destinationPath = path.join(destination, file)
      const destDir = path.dirname(destinationPath)
      await fs.promises.mkdir(destDir, { recursive: true})
      await fs.promises.copyFile(sourcePath, destinationPath)
    })
  )

  return destination
}

最终提交到官网 Git 仓库还做了一些其余的改变,然而总体的思路不变。感兴趣能够去查看具体的 pr 后果。

论断

通过上述实现步骤,咱们胜利地实现了自定义疏忽文件或目录的性能,能够让咱们更加不便地批改依赖包而防止不必要或谬误的批改。须要留神的是,咱们并没有批改 pnpm patch-package 自身的代码,而是在现有的设置根底上,减少了一个自定义疏忽列表。这样既不会影响原来的性能,也不会引入新的依赖或危险。

当然,这种办法还有一些局限性。例如,它只实用于应用 files 字段的我的项目。并且须要编写大量的文件列表。期待有更好的计划。

参考资料

  • pnpm patch-package 官网文档
  • How to exclude files from diff at pnpm patch #6565
  • unified diff format
  • Git diff documentation
  • patch-package repository

正文完
 0