轉自https://juejin.cn/post/6902992287974817806#heading-1
背景某次更新common包的時候筆者踩了很多坑,沒想到簡單的一個npm install命令背後還有這麼多的學問(keng),於是藉此機會學習一下,也將個人思考與大家分享。
更新歷史:1、npm@3.0: 包的扁平化
2、[npm@5.0][npm_5.0]:install根據lock文件來,即使更改了package.json文件,只要有lock文件,那麼還是會根據lock文件安裝
3、[npm@5.1][npm_5.1]:install無視lock文件,只要json文件改了,那麼就根據json的來
4、[npm@5.4][npm_5.1]:lock和package文件都兼顧,如果package.json和lock文件不同,就根據package.json文件去更新包,並且更新lock文件,如果他們是一樣的,那麼會去根據lock文件更新包。
install過程下面是步驟的簡單介紹,如果對整個流程有興趣的話可以看下[這個地方][Link 1]。
npm 向 registry 查詢模塊壓縮包的網址解壓壓縮包到當前項目的node_modules目錄源碼簡析如果電腦上已經全局安裝了node,通過npm config get prefix獲取到npm安裝的全局路徑prefixUrl,然後就可以得到npm的代碼位置:${prefixUrl}/lib/node_modules/npm。在看install代碼時直接跳到到npm文件夾下install.js。看下面部分最好結合源碼部分一起看,這樣看起來更輕鬆。
初始化構造函數:代碼截圖:
在ndTest文件夾,執行:npm i XXX
得到初始化變量
部分變量說明
dryrun : --dry-run: true不執行安裝,是否只列印信息currentTree:當前模塊的包含的信息,比如node_modules中中包構成的treeidealTree:安裝相應的模塊後,經過處理的包信息, 可以看成是目標 treeTodo: 用於存放運行的動作,在安裝過程中executeActions中看到它起的作用progress: 在run的newTracker過程中可以看到填充進去的processnoPackageJsonOk :沒有packageJson文件是否ok,變量的作用在readLocalPackageData使用到,用於如果直接執行npm install,那麼必須要有package.jsonautoPrune 布爾值,讀取配置文件 package-lock 判斷是否生成package-lock.json文件packageLockOnly 如果為true,不進行安裝動作,如果項目下面不存在lock文件,可以試一下這個命令npm i --package-lock-only體驗一把。[Chain][]首先先看這個函數,因為這個函數幾乎貫穿了整個install的過程中,對於它的了解更有助於接下來的閱讀。
chain.first = {} ; chain.last = {}
function chain (things, cb) {
var res = [];
(function LOOP (i, len) {
if (i >= len) return cb(null,res)
if (Array.isArray(things[i]))
things[i] = bindActor.apply(null,
things[i].map(function(i){
return (i===chain.first) ? res[0]
: (i===chain.last)
? res[res.length - 1] : i }))
if (!things[i]) return LOOP(i + 1, len)
things[i](function (er, data) {
if (er) return cb(er, res)
if (data !== undefined) res = res.concat(data)
LOOP(i + 1, len)
})
})(0, things.length) }
function bindActor () {
var args =
Array.prototype.slice.call
(arguments) *// jswtf.*
, obj = null
, fn
if (typeof args[0] === "object") {
obj = args.shift()
fn = args.shift()
if (typeof fn === "string")
fn = obj[ fn ]
} else fn = args.shift()
return function (cb) {
fn.apply(obj, args.concat(cb)) }
}
安裝過程(run)主要步驟run的過程就是對上面那張圖的具體實現了,從構建邏輯樹到去網絡獲取包,從校驗包的完整性到解壓包到項目的node_modules文件夾,最後生成或者更新package-lock.json文件。
這裡先簡單介紹這些步驟,其中loadCurrentTree、loadIdealTree用於構建得到邏輯樹,generateActionsToTake比較兩棵樹生成需要執行的動作存儲到TODO中,executeActions真正的執行TODO中的動作。我們在install過程中如果使用verbose級別的log也可以看到各個階段的日誌。
loadCurrentTree這個步驟主要經歷讀取PackageData和序列化讀取到的結果。
初步currentTree:讀node_modules裡面的包和npm-shrinkwrap.json構成初步tree。
readPackageTree(self.where, iferr(cb, function (currentTree) 從node_modules裡面讀包。以下是不存在node_modules時的截圖,這個時候得到的樹應該是最簡單的,如果存在node_modules,那麼這些信息會被放在children中。在讀取node_modules信息時,會過濾首字母為"."的文件夾,比如.bin。在獲取到self.loadArgMetadata(cb如果是帶args的安裝,那麼會去獲取這個args的元信息,元信息的結果demo:
獲取args信息的過程中出現一個很重要代碼,如下,詳細可以看lib/fetch-package-metadata.js代碼,這塊代碼用於獲取指定包的信息,
pacote.manifest(dep, pacoteOpts({
annotate: true,
fullMetadata: opts.fullMetadata,
log: tracker || npmlog,
memoize: CACHE,
where: where
})).then(normalize之後:this.currentTree.isTop = true算是給idealTree打基礎
computeMetadata(this.currentTree)掛missingDeps和missingDevDeps:計算當前得到的currentTree中是否含有package.json中dependencies和devDependencies的包,如果沒有則把它放入對應的missing數組中。當然也會做其他的事情,比如computeMetadata中的resetMetadata會給它和所有的child節點初始化上,如下:
child.isTop = false
child.requiredBy = []
child.requires = []
child.missingDeps = {}
child.missingDevDeps = {}
child.phantomChildren = {}
child.location = null如果執行命令時已經存在了node_modules,那麼會形成child和parent的循環引用,如圖:
loadIdealTree這個步驟主要執行三個:cloneCurrentTreeToIdealTree、loadShrinkwrap、loadAllDepsIntoIdealTree。
cloneCurrentTreeToIdealTree:
this.idealTree = copyTree(this.currentTree):把currentTree複製給IdealTree,代碼比較短,是個遞歸調用,具體可以在install/copy-tree.js中看到。
loadShrinkwrap:
readShrinkwrap.andInflate(this.idealTree:依次讀取npm-shrinkwrap.json、package-lock.json、package.json,如果存在lock文件或者shrinkwrap文件,讀取到內容之後掛載到_shrinkwrap欄位,然後在install/inflate-shrinkwrap.js中重新構建一顆新的樹,形成了ideaTree的雛形。
具體代碼可以看install/inflate-shrink-wrap.js的inflatableChild方法:1、判斷已經存在包信息和lock文件中要求的是否一直,如果一直,就用這個包的信息,2、如果不存在,但是滿足構造信息的條件,比如存在integrity,那麼fake一個包信息,3、都不滿足,利用前面講的fetchPackageMetadata去獲取之後創建一個節點信息。
這裡也會運行computeMetadata(this.idealTree),類似上面normalize步驟,看lock文件中是否有dependencies和devDependencies的包信息,如果沒有那麼將信息存儲到對應的missing數組中。
loadAllDepsIntoIdealTree:這個步驟裡面還是嵌套了chain,根據是否制定了安裝參數鏈進去不同的步驟,不過做的事情其實算是大同小異吧,主要代碼在install/deps.js文件。這裡針對指定了參數的分支進行介紹:
首先開場部分,獲取到這次安裝的類型,如:optionalDependencies、devDependencies、、dependencies等等,這裡可以看見,如果沒有指定安裝類型,那麼默認作為dependencies。
再去校驗參數的有效性,將前面已經獲取到的args信息去validate-args.js文件校驗,這裡也體現了代碼的嚴謹性吧。
然後就是核心部分了,loadRequestedDeps將這個args加到idealTree上去,這個步驟會涉及到包的打平。可以將在args看成是一顆小tree,把小tree上的節點加載到idealTree上的過程中就會涉及到包比較:這個包信息是否已經存在idealTree上了或者滿足是符合semver規則,也可以看見安裝包的版本是怎樣變化的,所安裝包a的依賴b是不是和在開發包a時指定需要的依賴包b一樣,也可以看見怎麼為一個包找到它可以掛載的最高的父節點。下面是這個方法重要的幾個部分,大致是:slide包裡面asyncMap並行去處理參數,resolveWithNewModule處理小tree的最頂層節點,andForEachChild在之後對每個自節點進行處理,這個過程是比較耗時的*。*
debugTree、debugActions這兩個步驟沒有進行很重要的處理,debugTree對currentTree和IdealTree使用[archy][]進行處理,對依賴形成一個樹形依賴結構,類似:
同樣,debugActions也是對得到的actions進行類似log的處理:
generateActionsToTake這個步驟主要進行的動作有:
1、validateTree:遍歷idealTree,查看當前包進行安裝安裝的時候,是否滿足peer dependencies,如果不滿足那麼輸出warning;
2、diffTrees:比較currentTree和idealTree,首先將這兩棵樹使用flattenTree打平,從根結點的「/」key值開始,每個key都是相對於根結點的路徑,比如/@babel/plugin-proposal-dynamic-import。兩棵樹初步對比會得到那些包add、哪些包remove、哪些包update等等。如下圖:
然後把這些數據進行過濾,具體的過濾可看filterActions方法,得到的結果放入this.differences,這個步驟有個有意思的方法,function *pkgAreEquiv*(aa, bb) 比較兩個包是否相同。differences截圖示例,如果不存在node_modules的項目,那麼應該全是「add」,如果是安裝指定的包,那麼可能有「add」、"update"、"remove"、「move」等。
3、computeLinked:如果diff不為空,那麼檢測緩存,連結到合適的全局包,如果在全局存在,那麼將它從tree中移除
4、checkPermissions:如果diff不是空,檢測當前執行的動作在需要執行的目錄下面的權限,對於add、update\remove、move動作進行對應的權限檢測。
5、decomposeActions:如果diff不為空,對diff進行區別處理,根據cmd的不同,add、update、move、remove以及默認類型,對它進行不同處理之後加入到todo中。todo截圖示例:
startAudit對安裝的包進行audit,function *generateFromInstall* (tree, diffs, install, remove)根據前面得到的actions和diffs,更新metaData。下面是auditData部分截圖:
並且返回 generateMetadata()這個promise,在後面executeActions步驟中使用。
executeActions開始真正的安裝動作,執行todo裡面所有的動作。裡面的動作分:doParallelActions、doSerialActions、lock、rimraf四種。
執行過程部分輸出截圖demo:
這裡對install源碼進行簡單介紹,對於這種過程複雜的代碼使用調試手段輔助應該會更好,可以去[node官網看一圈][node]。介紹一種簡單的調試方法,新建一個node項目之後,在package.json的script中添加腳本:"npm-debug":「node --inspect-brk=5858 ${prefixUrl}/lib/node_modules/npm/bin/npm-cli.js install [option react@16.8.0]」。在調試的過程中一邊調試一邊看控制臺的輸出信息以及node_modules中的變化應該可以對安裝過程有更多的了解。
npm緩存文件夾一直都比較好奇npm的緩存是把文件存在哪裡的,然後文件存放路徑選擇的依據是什麼,現在來根據緩存裡的數據倒推一下:npm的緩存文件夾.npm中存在_cacache文件夾,裡面存在content-v2、index-v5、tmp。index-v5索引文件夾,content-v2存放壓縮包的文件夾。
索引:在index-v5中搜索archy,獲取它的索引信息,得到哈希值:9323fd73c05db8792d37c9c0729f5f570e2d231672968fb966ba05443b446bc6,這個哈希值就是存儲的路徑了,不過為什麼會是這個哈希呢,下面找下原因:
打開文件index-v5/93/23/fd73c05db8792d37c9c0729f5f570e2d231672968fb966ba05443b446bc6:f819cc633198b457309887b93e50fe591aed55ec獲取到key:make-fetch-happen:request-cache:http://bnpm.byted.org/archy/download/archy-1.0.0.tgz,然後[實驗][Link 2]常用hash算法,得到使用sha256對這個key計算之後得到的索引存儲位置,也就是得到這個哈希值。
壓縮包:得到索引之後,將文件格式化得到:
觀察它們的值,貌似只有etag有可能是需要的,etag為f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40,那麼找到content-v2/sha1/f9/c8/c13757cc1dd7bc379ac77b2c62a5c2868c40果然看見了這個文件,顯示的信息和metadata裡面的其他信息符合。
解壓它:tar tf content-v2/sha1/f9/c8/c13757cc1dd7bc379ac77b2c62a5c2868c40,得到:
裡面的內容和archy相同。
在有package-lock的情況下,根據lock裡面的包名、版本、integrity拼一個key出來去找meta信息。沒有lock就只能去請求registry了,請求下來也有一樣的meta信息