尤雨溪推薦神器 ni ,能替代 npm/yarn/pnpm ?簡單好用!原始碼揭祕!

若川發表於2021-11-10

1. 前言

大家好,我是若川。最近組織了原始碼共讀活動,感興趣的可以加我微信 ruochuan12 參與,已進行兩個多月,大家一起交流學習,共同進步。

想學原始碼,極力推薦之前我寫的《學習原始碼整體架構系列》 包含jQueryunderscorelodashvuexsentryaxiosreduxkoavue-devtoolsvuex4koa-composevue-next-releasevue-thiscreate-vue等十餘篇原始碼文章。

本文倉庫 ni-analysis,求個star^_^

最近組織了原始碼共讀活動,大家一起學習原始碼。於是搜尋各種值得我們學習,且程式碼行數不多的原始碼。

之前寫了 Vue3 相關的兩篇文章。

文章裡都是寫的使用 yarn 。參加原始碼共讀的小夥伴按照我的文章,卻拉取的最新倉庫程式碼,發現 yarn install 安裝不了依賴,向我反饋報錯。於是我去 github倉庫 一看,發現尤雨溪把 Vue3倉庫yarn 換成了 pnpm貢獻文件中有一句話。

We also recommend installing ni to help switching between repos using different package managers. ni also provides the handy nr command which running npm scripts easier.

我們還建議安裝 ni 以幫助使用不同的包管理器在 repos 之間切換。 ni 還提供了方便的 nr 命令,可以更輕鬆地執行 npm 指令碼。

這個 ni 專案原始碼雖然是 ts,沒用過 ts 小夥伴也是很好理解的,而且主檔案其實不到 100行,非常適合我們學習。

閱讀本文,你將學到:

1. 學會 ni 使用和理解其原理
2. 學會除錯學習原始碼
3. 可以在日常工作中也使用 ni
4. 等等

2. 原理

github 倉庫 ni#how

ni 假設您使用鎖檔案(並且您應該)

在它執行之前,它會檢測你的 yarn.lock / pnpm-lock.yaml / package-lock.json 以瞭解當前的包管理器,並執行相應的命令。

單從這句話中可能有些不好理解,還是不知道它是個什麼。我解釋一下。

使用 `ni` 在專案中安裝依賴時:
   假設你的專案中有鎖檔案 `yarn.lock`,那麼它最終會執行 `yarn install` 命令。
   假設你的專案中有鎖檔案 `pnpm-lock.yaml`,那麼它最終會執行 `pnpm i` 命令。
   假設你的專案中有鎖檔案 `package-lock.json`,那麼它最終會執行 `npm i` 命令。

使用 `ni -g vue-cli` 安裝全域性依賴時
    預設使用 `npm i -g vue-cli`

當然不只有 `ni` 安裝依賴。
    還有 `nr` - run
    `nx` - execute
    `nu` - upgrade
    `nci` - clean install
    `nrm` - remove

我看原始碼發現:ni相關的命令,都可以在末尾追加\?,表示只列印,不是真正執行

所以全域性安裝 ni 後,可以盡情測試,比如 ni \?nr dev --port=3000 \?,因為列印,所以可以在各種目錄下執行,有助於理解 ni 原始碼。我測試瞭如下圖所示:

命令測試圖示

假設專案目錄下沒有鎖檔案,預設就會讓使用者從npm、yarn、pnpm選擇,然後執行相應的命令。
但如果在~/.nirc檔案中,設定了全域性預設的配置,則使用預設配置執行對應命令。

Config

; ~/.nirc

; fallback when no lock found
defaultAgent=npm # default "prompt"

; for global installs
globalAgent=npm

因此,我們可以得知這個工具必然要做三件事

1. 根據鎖檔案猜測用哪個包管理器 npm/yarn/pnpm 
2. 抹平不同的包管理器的命令差異
3. 最終執行相應的指令碼

接著繼續看看 README 其他命令的使用,就會好理解。

3. 使用

ni github文件

npm i in a yarn project, again? F**k!

ni - use the right package manager

全域性安裝。

npm i -g @antfu/ni

如果全域性安裝遭遇衝突,我們可以加上 --force 引數強制安裝。

舉幾個常用的例子。

3.1 ni - install

ni

# npm install
# yarn install
# pnpm install
ni axios

# npm i axios
# yarn add axios
# pnpm i axios

3.2 nr - run

nr dev --port=3000

# npm run dev -- --port=3000
# yarn run dev --port=3000
# pnpm run dev -- --port=3000
nr
# 互動式選擇命令去執行
# interactively select the script to run
# supports https://www.npmjs.com/package/npm-scripts-info convention
nr -

# 重新執行最後一次執行的命令
# rerun the last command

3.3 nx - execute

nx jest

# npx jest
# yarn dlx jest
# pnpm dlx jest

4. 閱讀原始碼前的準備工作

4.1 克隆

# 推薦克隆我的倉庫(我的保證對應文章版本)
git clone https://github.com/lxchuan12/ni-analysis.git
cd ni-analysis/ni
# npm i -g pnpm
# 安裝依賴
pnpm i
# 當然也可以直接用 ni

# 或者克隆官方倉庫
git clone https://github.com/antfu/ni.git
cd ni
# npm i -g pnpm
# 安裝依賴
pnpm i
# 當然也可以直接用 ni

眾所周知,看一個開源專案,先從 package.json 檔案開始看起。

4.2 package.json 檔案

{
    "name": "@antfu/ni",
    "version": "0.10.0",
    "description": "Use the right package manager",
    // 暴露了六個命令
    "bin": {
        "ni": "bin/ni.js",
        "nci": "bin/nci.js",
        "nr": "bin/nr.js",
        "nu": "bin/nu.js",
        "nx": "bin/nx.js",
        "nrm": "bin/nrm.js"
    },
    "scripts": {
        // 省略了其他的命令 用 esno 執行 ts 檔案
        // 可以加上 ? 便於除錯,也可以不加
        // 或者是終端 npm run dev \?
        "dev": "esno src/ni.ts ?"
    },
}

根據 dev 命令,我們找到主入口檔案 src/ni.ts

4.3 從原始碼主入口開始除錯

// ni/src/ni.ts
import { parseNi } from './commands'
import { runCli } from './runner'

// 我們可以在這裡斷點
runCli(parseNi)

找到 ni/package.jsonscripts,把滑鼠移動到 dev 命令上,會出現執行指令碼除錯指令碼命令。如下圖所示,選擇除錯指令碼。

VSCode 除錯

VSCode 除錯 Node.js 說明

5. 主流程 runner - runCli 函式

這個函式就是對終端傳入的命令列引數做一次解析。最終還是執行的 run 函式。

對於 process 不瞭解的讀者,可以看阮一峰老師寫的 process 物件

// ni/src/runner.ts
export async function runCli(fn: Runner, options: DetectOptions = {}) {
  // process.argv:返回一個陣列,成員是當前程式的所有命令列引數。
  // 其中 process.argv 的第一和第二個元素是Node可執行檔案和被執行JavaScript檔案的完全限定的檔案系統路徑,無論你是否這樣輸入他們。
  const args = process.argv.slice(2).filter(Boolean)
  try {
    await run(fn, args, options)
  }
  catch (error) {
    // process.exit方法用來退出當前程式。它可以接受一個數值引數,如果引數大於0,表示執行失敗;如果等於0表示執行成功。
    process.exit(1)
  }
}

我們接著來看,run 函式。

6. 主流程 runner - run 主函式

這個函式主要做了三件事

1. 根據鎖檔案猜測用哪個包管理器 npm/yarn/pnpm - detect 函式
2. 抹平不同的包管理器的命令差異 - parseNi 函式
3. 最終執行相應的指令碼 - execa 工具
// ni/src/runner.ts
// 原始碼有刪減
import execa from 'execa'
const DEBUG_SIGN = '?'
export async function run(fn: Runner, args: string[], options: DetectOptions = {}) {
  // 命令引數包含 問號? 則是除錯模式,不執行指令碼
  const debug = args.includes(DEBUG_SIGN)
  if (debug)
    // 除錯模式下,刪除這個問號
    remove(args, DEBUG_SIGN)

  // cwd 方法返回程式的當前目錄(絕對路徑)
  let cwd = process.cwd()
  let command

  // 支援指定 檔案目錄
  // ni -C packages/foo vite
  // nr -C playground dev
  if (args[0] === '-C') {
    cwd = resolve(cwd, args[1])
    // 刪掉這兩個引數 -C packages/foo
    args.splice(0, 2)
  }

  // 如果是全域性安裝,那麼實用全域性的包管理器
  const isGlobal = args.includes('-g')
  if (isGlobal) {
    command = await fn(getGlobalAgent(), args)
  }
  else {
    let agent = await detect({ ...options, cwd }) || getDefaultAgent()
    // 猜測使用哪個包管理器,如果沒有發現鎖檔案,會返回 null,則呼叫 getDefaultAgent 函式,預設返回是讓使用者選擇 prompt
    if (agent === 'prompt') {
      agent = (await prompts({
        name: 'agent',
        type: 'select',
        message: 'Choose the agent',
        choices: agents.map(value => ({ title: value, value })),
      })).agent
      if (!agent)
        return
    }
    // 這裡的 fn 是 傳入解析程式碼的函式
    command = await fn(agent as Agent, args, {
      hasLock: Boolean(agent),
      cwd,
    })
  }

  // 如果沒有命令,直接返回,上一個 runCli 函式報錯,退出程式
  if (!command)
    return

  // 如果是除錯模式,那麼直接列印出命令。除錯非常有用。
  if (debug) {
    // eslint-disable-next-line no-console
    console.log(command)
    return
  }

  // 最終用 execa 執行命令,比如 npm i
  // https://github.com/sindresorhus/execa
  // 介紹:Process execution for humans

  await execa.command(command, { stdio: 'inherit', encoding: 'utf-8', cwd })
}

我們學習完主流程,接著來看兩個重要的函式:detect 函式、parseNi 函式。

根據入口我們可以知道。

runCli(parseNi)

run(fn)

這裡 fn 則是 parseNi

6.1 根據鎖檔案猜測用哪個包管理器(npm/yarn/pnpm) - detect 函式

程式碼相對不多,我就全部放出來了。

主要就做了三件事情

1. 找到專案根路徑下的鎖檔案。返回對應的包管理器 `npm/yarn/pnpm`。
2. 如果沒找到,那就返回 `null`。
3. 如果找到了,但是使用者電腦沒有這個命令,則詢問使用者是否自動安裝。
// ni/src/agents.ts
export const LOCKS: Record<string, Agent> = {
  'pnpm-lock.yaml': 'pnpm',
  'yarn.lock': 'yarn',
  'package-lock.json': 'npm',
}
// ni/src/detect.ts
export async function detect({ autoInstall, cwd }: DetectOptions) {
  const result = await findUp(Object.keys(LOCKS), { cwd })
  const agent = (result ? LOCKS[path.basename(result)] : null)

  if (agent && !cmdExists(agent)) {
    if (!autoInstall) {
      console.warn(`Detected ${agent} but it doesn't seem to be installed.\n`)

      if (process.env.CI)
        process.exit(1)

      const link = terminalLink(agent, INSTALL_PAGE[agent])
      const { tryInstall } = await prompts({
        name: 'tryInstall',
        type: 'confirm',
        message: `Would you like to globally install ${link}?`,
      })
      if (!tryInstall)
        process.exit(1)
    }

    await execa.command(`npm i -g ${agent}`, { stdio: 'inherit', cwd })
  }

  return agent
}

接著我們來看 parseNi 函式。

6.2 抹平不同的包管理器的命令差異 - parseNi 函式

// ni/src/commands.ts
export const parseNi = <Runner>((agent, args, ctx) => {
  // ni -v 輸出版本號
  if (args.length === 1 && args[0] === '-v') {
    // eslint-disable-next-line no-console
    console.log(`@antfu/ni v${version}`)
    process.exit(0)
  }

  if (args.length === 0)
    return getCommand(agent, 'install')
  // 省略一些程式碼
})

通過 getCommand 獲取命令。

// ni/src/agents.ts
// 有刪減
// 一份配置,寫個這三種包管理器中的命令。

export const AGENTS = {
  npm: {
    'install': 'npm i'
  },
  yarn: {
    'install': 'yarn install'
  },
  pnpm: {
    'install': 'pnpm i'
  },
}
// ni/src/commands.ts
export function getCommand(
  agent: Agent,
  command: Command,
  args: string[] = [],
) {
  // 包管理器不在 AGENTS 中則報錯
  // 比如 npm 不在
  if (!(agent in AGENTS))
    throw new Error(`Unsupported agent "${agent}"`)

  // 獲取命令 安裝則對應 npm install
  const c = AGENTS[agent][command]

  // 如果是函式,則執行函式。
  if (typeof c === 'function')
    return c(args)

  // 命令 沒找到,則報錯
  if (!c)
    throw new Error(`Command "${command}" is not support by agent "${agent}"`)
  // 最終拼接成命令字串
  return c.replace('{0}', args.join(' ')).trim()
}

6.3 最終執行相應的指令碼

得到相應的命令,比如是 npm i,最終用這個工具 execa 執行最終得到的相應的指令碼。

await execa.command(command, { stdio: 'inherit', encoding: 'utf-8', cwd })

7. 總結

我們看完原始碼,可以知道這個神器 ni 主要做了三件事

1. 根據鎖檔案猜測用哪個包管理器 npm/yarn/pnpm - detect 函式
2. 抹平不同的包管理器的命令差異 - parseNi 函式
3. 最終執行相應的指令碼 - execa 工具

我們日常開發中,可能容易 npmyarnpnpm 混用。有了 ni 後,可以用於日常開發使用。Vue 核心成員 Anthony Fu 發現問題,最終開發了一個工具 ni 解決問題。而這種發現問題、解決問題的能力正是我們前端開發工程師所需要的。

另外,我發現 Vue 生態很多基本都切換成了使用 pnpm

因為文章不宜過長,所以未全面展開講述原始碼中所有細節。非常建議讀者朋友按照文中方法使用VSCode除錯 ni 原始碼。學會除錯原始碼後,原始碼並沒有想象中的那麼難

最後可以持續關注我@若川。歡迎加我微信 ruochuan12 交流,參與 原始碼共讀 活動,大家一起學習原始碼,共同進步。

相關文章