动态

详情 返回 返回

【源碼】pnpm源碼分析 - 动态 详情

今天我們開始閲讀pnpm的源碼,深入瞭解pnpm安裝原理,先上圖,pnpm安裝的整體的核心流程如下:
image.png
下面我們開始逐步分析。

一、從哪裏開始

每次説到源碼,不太熟悉的人總會有種無從下手的感覺,而pnpm又與我們的框架源碼比如vue又有所不同,因為它是全局安裝的。
前端的小夥伴都知道,全局安裝的依賴不在項目中,那如何找到全局安裝目錄呢?可以通過下面的命令查看:

npm config get prefix

它通常會有以下目錄:
圖片
這裏我們主要關心2個目錄,bin與lib。bin裏面包含我們所有的全局命令,lib則是所有的全局依賴所在。
圖片

圖片
我們想看全局命令pnpm如何執行的,那就直接看bin/pnpm文件就好了,可以清晰的看見大部分bin下的文件都是一個鏈接,可以右擊查看源文件內容:
圖片
它最終指向了lib/node_modules/pnpm內的文件:
圖片

二、主入口函數

這裏我們以最新版本pnpm@9.5.0-beta.3進行分析。

// bin/pnpm.cjs 
require('../dist/pnpm.cjs')bin/pnpm.cjs

內容比較簡單,它直接指向了dist/pnpm.cjs。
由於我們是直接打開的全局安裝依賴的文件,dist/pnpm.cjs是打包後的比較大的文件,直接閲讀這個源碼還是太費勁了。
有2種方式可以找到入口函數:

  • 為確保調用的函數/變量已經定義了,通常開發者會將主入口函數定義到文件最後。因此可以直接拉到文件末尾,可以找到一個自執行函數,這就是我們的主入口函數:

    
    var argv = process.argv.slice(2);
    (async () => {
    switch (argv[0]) {
      case "-v":
      case "--version": {
        const { version: version2 } = (await Promise.resolve().then(() => __importStar2(require_lib4()))).packageManager;
        console.log(version2);
        break;
      }
      case "access":
      case "adduser":
      case "bugs":
      case "deprecate":
      case "dist-tag":
      case "docs":
      case "edit":
      case "home":
      case "info":
      case "login":
      case "logout":
      case "owner":
      case "ping":
      case "prefix":
      case "profile":
      case "pkg":
      case "repo":
      case "s":
      case "se":
      case "search":
      case "set-script":
      case "show":
      case "star":
      case "stars":
      case "team":
      case "token":
      case "unpublish":
      case "unstar":
      case "v":
      case "version":
      case "view":
      case "whoami":
      case "xmas":
        await passThruToNpm();
        break;
      default:
        await runPnpm();
        break;
    }
    })();
  • 利用vs code的斷點能力,可以在我們的項目中定義.vscode/launch.json文件:
    圖片

通過以上操作會自動在當前項目根目錄下創建文件.vscode/launch.json,這裏我們將其內容替換為:


{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
      {
        "type": "node",
        "request": "launch",
        "name": "pnpm源碼分析",
        "cwd": "${workspaceRoot}",
        "runtimeExecutable": "pnpm",
        "runtimeArgs": [ "install"],
        "console": "integratedTerminal",
        "protocol": "auto",
        "restart": true,
        "port": 9999,
        "autoAttachChildProcesses": true
      }
    ]
}

這裏配置的是一個pnpm install的命令,也是我們本次分析的命令。為什麼這麼配置?有興趣的小夥伴可以閲讀官方文檔:Launch configurations(https://go.microsoft.com/fwlink/?linkid=830387)。配置完成後,回到剛剛的tab,可以看到有一個名為“pnpm源碼分析的”命令,可以點擊名字左側的運行按鈕執行。
圖片
在運行之前,我們還需要做一件事情,就是為我們的源碼添加斷點(即在需要斷點的地方所在行前面點一下即可,設置斷點後將看到一個小紅圓點):
圖片
執行命令後,將在此處停留,與我們熟悉的在瀏覽器打斷點類似,可以逐步分析每一步的執行:
圖片
它會進入dist/pnpm.cjs逐步執行,最終也會走入到文件末尾的自執行函數那裏。process.argv是node提供的一個屬性,用於獲取命令行參數,process.argv 的前兩個元素是固定的:

  • process.argv[0] 是 Node.js 可執行文件的路徑。
  • process.argv[1] 是當前執行的 JavaScript 文件的路徑。

從第三個元素開始,才是用户傳遞的命令行參數。
圖片
當前我們運行的是pnpm install,因此將調用runPnpm函數:

async function runPnpm() {
  const { errorHandler } = await Promise.resolve().then(() => __importStar2(require_errorHandler()));
  try {
    const { main } = await Promise.resolve().then(() => __importStar2(require_main3()));
    await main(argv);
  } catch (err) {
    await errorHandler(err);
  }
}

解析後的代碼比較沒有源碼好看,可以對照着分析,即:

// pnpm/src/pnpm.ts
async function runPnpm (): Promise<void> {
  const { errorHandler } = await import('./errorHandler')
  try {
    const { main } = await import('./main')
    await main(argv)
  } catch (err: any) { // eslint-disable-line
    await errorHandler(err)
  }
}

三、分析pnpm install

main函數位於pnpm/src/main.ts,整個函數比較長,整體流程簡化如下:
共分幾步,下面我們詳細介紹。

(一)將命令行參數格式化

try {
    parsedCliArgs = await parseCliArgs(inputArgv)
} catch (err: any) { ... }

在/pnpm/src/parseCliArgs.ts中
image.png

(二)獲取pnpm的配置

它會讀取如下內容,並按照優先級合併:

  • 全局配置文件
  • 項目級配置文件(通常是 .npmrc 文件)
  • 環境變量
  • 命令行參數

代碼如下:

const {
    argv, // 'install'
    params: cliParams, // []
    options: cliOptions, // {}
    cmd, // 'install'
    fallbackCommandUsed, // false
    unknownOptions, // Map(0) { size: 0 }
    workspaceDir, // "/Users/huigao/Documents/WORKSPACE/Learning/pnpm-example"
} = parsedCliArgs
// ...
let config
try {
    // When we just want to print the location of the global bin directory,
    // we don't need the write permission to it. Related issue: #2700
    const globalDirShouldAllowWrite = cmd !== 'root' // true
    const isDlxCommand = cmd === 'dlx' // false
    // 獲取pnpm配置
    config = await getConfig(cliOptions, {
      excludeReporter: false,
      globalDirShouldAllowWrite,
      rcOptionsTypes,
      workspaceDir,
      checkUnknownSetting: false
      ignoreNonAuthSettingsFromLocal: isDlxCommand,
    }) as typeof config
    // ...
    config.argv = argv
    config.fallbackCommandUsed = fallbackCommandUsed
    // Set 'npm_command' env variable to current command name
    if (cmd) {
      config.extraEnv = {
        ...config.extraEnv,
        // Follow the behavior of npm by setting it to 'run-script' when running scripts (e.g. pnpm run dev)
        // and to the command name otherwise (e.g. pnpm test)
        npm_command: cmd === 'run' ? 'run-script' : cmd,
      }
    }
} catch (err: any) { ... }
// ...
if (
    (cmd === 'install' || cmd === 'import' || cmd === 'dedupe' || cmd === 'patch-commit' || cmd === 'patch' || cmd === 'patch-remove') &&
  typeof workspaceDir === 'string'
) {
    cliOptions['recursive'] = true
    config.recursive = true

    if (!config.recursiveInstall && !config.filter && !config.filterProd) { // false
      config.filter = ['{.}...']
    }
}

getConfig實現如下:
圖片
最終得到的config如下(內容較多,這裏就截取部分):
圖片

(三)讀取pnpm工作區


if (cliOptions['recursive']) { // true
    const wsDir = workspaceDir ?? process.cwd() // /Users/huigao/Documents/WORKSPACE/Learning/pnpm-example
    config.filter = config.filter ?? [] // []
    config.filterProd = config.filterProd ?? [] // []
    const filters = [
      ...config.filter.map((filter) => ({ filter, followProdDepsOnly: false })),
      ...config.filterProd.map((filter) => ({ filter, followProdDepsOnly: true })),
    ] // []
    const relativeWSDirPath = () => path.relative(process.cwd(), wsDir) || '.'
    // ...
    const filterResults = await filterPackagesFromDir(wsDir, filters, {
      engineStrict: config.engineStrict, // false
      nodeVersion: config.nodeVersion ?? config.useNodeVersion, // undefined
      patterns: config.workspacePackagePatterns, // ['packages/*']
      linkWorkspacePackages: !!config.linkWorkspacePackages, // false
      prefix: process.cwd(), // /Users/huigao/Documents/WORKSPACE/Learning/pnpm-example
      workspaceDir: wsDir, // /Users/huigao/Documents/WORKSPACE/Learning/pnpm-example
      testPattern: config.testPattern, // undefined
      changedFilesIgnorePattern: config.changedFilesIgnorePattern, // undefined
      useGlobDirFiltering: !config.legacyDirFiltering, // config.legacyDirFiltering: undefined
      sharedWorkspaceLockfile: config.sharedWorkspaceLockfile, // true
    })

    if (filterResults.allProjects.length === 0) {
      if (printLogs) {
        console.log(`No projects found in "${wsDir}"`)
      }
      process.exitCode = config.failIfNoMatch ? 1 : 0
      return
    }
    config.allProjectsGraph = filterResults.allProjectsGraph
    config.selectedProjectsGraph = filterResults.selectedProjectsGraph
    // ...
    config.allProjects = filterResults.allProjects
    config.workspaceDir = wsDir
}

重點在於filterPackagesFromDir函數,它定義在@pnpm/filter-workspace-packages中:
圖片
它總共分為3塊部分:
1、讀取並解析根目錄下與所有工作區內的項目下的package文件,支持3種格式:package.json、package.json5、package.yaml。

圖片
2、校驗根目錄與所有工作區的package文件配置環境在當前環境中是否適用,包含系統、node版本與pnpm版本。
圖片
3、規範化package文件中的依賴版本,並識別器工作區的依賴
圖片
最終拿到的filterResults如下:
圖片

(四)開始安裝

圖片
這裏只調用了2個函數:checkForUpdates、pnpmCmds[cmd ?? 'help']。

1、checkForUpdates

圖片
整個checkForUpdates分了3部分內容:
1)創建解析器createResolver
圖片
即:

圖片

  • createFetchFromRegistry:得到專門用於從 npm 註冊表(registry)獲取包的信息和內容函數
    圖片
  • createGetAuthHeaderByURI:得到一個可以根據給定的 URI 生成適當的認證頭信息的函數
    圖片
  • _createResolver:返回一個包含解析函數resolve的對象
    圖片

2)執行解析resolve
圖片
值得注意的是,這裏解析的是pnpm包(packageManager.name為"pnpm"):
圖片
registry得到的是pnpm的源:
圖片
resolve函數如下:
圖片
代碼非常清晰,優先從npm解析 -> 從Tarball解析 -> 從Git解析 -> 從本地解析

  • 從npm解析
    圖片
  • 從Tarball解析
    圖片
  • 從Git解析
    圖片
  • 從本地解析
    圖片

完整流程如下:
圖片
3)寫入JSON文件
引入write-json-file 實現,它是一個 Node.js 庫,用於將 JavaScript 對象寫入 JSON 文件。這個庫提供了一種簡單且可靠的方式來創建或更新 JSON 文件。checkForUpdates全流程如下:
圖片

2、pnpmCmds[cmd ?? 'help']對應install的handler函數。

圖片
完整的安裝流程我們前面也貼了,這裏再貼一下:
圖片


------------ 未完 ------------

更多請關注我的個人公眾號查看

圖片

Add a new 评论

Some HTML is okay.