這篇文章帶給大家的內容是關於Parcel原始碼的詳細分析(附範例),有一定的參考價值,有需要的朋友可以參考一下,希望對你有幫助。
這篇文章是對 Parce 的原始碼解析,程式碼基本架構與執行流程,在這之前你如果對 parcel 不熟悉可以先到Parcel官網了解
#介紹
下面是偷懶從官網抄下來的介紹:
極速零設定Web應用打包工具極速打包
Parcel 使用 worker 進程去啟用多核心編譯。同時有檔案系統緩存,即使在重新啟動建置後也能快速再編譯。
將你所有的資源打包
Parcel 具備開箱即用的對 JS, CSS, HTML, 文件 及更多的支持,而且不需要插件。
自動轉換
如若有需要,Babel, PostCSS, 和 PostHTML 甚至 node_modules 套件會被用於自動轉換碼.
零設定碼分拆
使用動態 import() 語法, Parcel 將你的輸出檔案束(bundles)分拆,因此你只需要在初次載入時載入你所需要的程式碼。
熱模組替換
Parcel 不需要配置,在開發環境的時候會自動在瀏覽器內隨著你的程式碼更改而去更新模組。
友善的錯誤日誌
當遇到錯誤時,Parcel 會輸出 語法高亮的程式碼片段,幫助你定位問題。
包裝工具 | |
---|---|
#browserify | 22.98 s |
webpack | 20.71s |
parcel | 9.98s |
#parcel - with cache | 2.64s |
打包工具
我們常用的打包工具大致功能:
模組化(程式碼的拆分, 合併, Tree-Shaking 等)編譯(es6,7,8 sass typescript 等)壓縮(js, css, html包含圖片的壓縮)HMR (熱替換)
version
parcel-bundler 版本:
"version": "1.11.0 "
檔案架構
|-- assets 资源目录 继承自 Asset.js |-- builtins 用于最终构建 |-- packagers 打包 |-- scope-hoisting 作用域提升 Tree-Shake |-- transforms 转换代码为 AST |-- utils 工具 |-- visitors 遍历 js AST树 收集依赖等 |-- Asset.js 资源 |-- Bundle.js 用于构建 bundle 树 |-- Bundler.js 主目录 |-- FSCache.js 缓存 |-- HMRServer.js HMR服务器提供 WebSocket |-- Parser.js 根据文件扩展名获取对应 Asset |-- Pipeline.js 多线程执行方法 |-- Resolver.js 解析模块路径 |-- Server.js 静态资源服务器 |-- SourceMap.js SourceMap |-- cli.js cli入口 解析命令行参数 |-- worker.js 多线程入口
流程
說明
Parcel是資源導向的,JavaScript,CSS,HTML 這些都是資源,並不是 webpack 中 js 是一等公民,Parcel 會自動的從入口檔案開始分析這些檔案和模組中的依賴,然後建構一個 bundle 樹,並對其進行打包輸出到指定目錄
#一個簡單的例子
我們從一個簡單的例子開始了解 parcel 內部源碼與流程
index.html |-- index.js |-- module1.js |-- module2.js
上面是我們例子的結構,入口為 index.html, 在 index.html 中我們用script 標籤引用了 src/index.js,在 index.js 中我們引入了2個子模組
執行
npx parcel index.html 或 ./node_modules/.bin/parcel index. html,或用 npm script
cli
"bin": { "parcel": "bin/cli.js" }
檢視 parcel-bundler的 package.json 找到 bin/cli.js,在cli.js裡又指向 ../src/cli
const program = require('commander'); program .command('serve [input...]') // watch build ... .action(bundle); program.parse(process.argv); async function bundle(main, command) { const Bundler = require('./Bundler'); const bundler = new Bundler(main, command); if (command.name() === 'serve' && command.target === 'browser') { const server = await bundler.serve(); if (server && command.open) {...启动自动打开浏览器} } else { bundler.bundle(); } }
在 cli.js 中利用 commander 解析指令列並呼叫 bundle 方法
有 serve, watch, build 3個指令來呼叫 bundle 函數,因此執行的是pracel index.html 默認為.serve 方法
進入 Bundler.js
bundler.serve
async serve(port = 1234, https = false, host) { this.server = await Server.serve(this, port, host, https); try { await this.bundle(); } catch (e) {} return this.server; }
bundler.serve 方法呼叫 serveStatic 起了一個靜態服務指向最終打包的資料夾
#下面是重要的 bundle 方法
bundler.bundle
async bundle() { // 加载插件 设置env 启动多线程 watcher hmr await this.start(); if (isInitialBundle) { // 创建 输出目录 await fs.mkdirp(this.options.outDir); this.entryAssets = new Set(); for (let entry of this.entryFiles) { let asset = await this.resolveAsset(entry); this.buildQueue.add(asset); this.entryAssets.add(asset); } } // 打包队列中的资源 let loadedAssets = await this.buildQueue.run(); // findOrphanAssets 获取所有资源中独立的没有父Bundle的资源 let changedAssets = [...this.findOrphanAssets(), ...loadedAssets]; // 因为接下来要构建 Bundle 树,先对上一次的 Bundle树 进行 clear 操作 for (let asset of this.loadedAssets.values()) { asset.invalidateBundle(); } // 构建 Bundle 树 this.mainBundle = new Bundle(); for (let asset of this.entryAssets) { this.createBundleTree(asset, this.mainBundle); } // 获取新的最终打包文件的url this.bundleNameMap = this.mainBundle.getBundleNameMap( this.options.contentHash ); // 将代码中的旧文件url替换为新的 for (let asset of changedAssets) { asset.replaceBundleNames(this.bundleNameMap); } // 将改变的资源通过websocket发送到浏览器 if (this.hmr && !isInitialBundle) { this.hmr.emitUpdate(changedAssets); } // 对资源打包 this.bundleHashes = await this.mainBundle.package( this, this.bundleHashes ); // 将独立的资源删除 this.unloadOrphanedAssets(); return this.mainBundle; }
我們一步一步先從 this.start 看
start
if (this.farm) { return; } await this.loadPlugins(); if (!this.options.env) { await loadEnv(Path.join(this.options.rootDir, 'index')); this.options.env = process.env; } if (this.options.watch) { this.watcher = new Watcher(); this.watcher.on('change', this.onChange.bind(this)); } if (this.options.hmr) { this.hmr = new HMRServer(); this.options.hmrPort = await this.hmr.start(this.options); } this.farm = await WorkerFarm.getShared(this.options, { workerPath: require.resolve('./worker.js') });
start:
開頭的判斷 防止多次執行,也就是說 this.start 只會執行一次loadPlugins 載入插件,找到 package.json 檔案 dependencies, devDependencies 中 parcel-plugin-開頭的插件來呼叫loadEnv dotenv-expand 套件將 env.development.local, .env.development, .env.local, .env 擴充至 process.envwatch 初始化監聽檔案並綁定 來監聽文件改變hmr 起一個服務,WebSocket 向瀏覽器發送更改的資源farm 初始化多進程並指定 werker 工作文件,開啟多個 child_process 去解析編譯資源
接下來回到 bundle,isInitialBundle 是一個判斷是否為第一次建置
fs.mkdirp 建立輸出資料夾
遍歷入口文件,透過 resolveAsset,內部呼叫 resolver 解析路徑,並 getAsset 取得對應的 asset(這裡我們入口是 index.html,根據副檔名取得的是 HTMLAsset)
將 asset 加入進隊列
然後啟動 this.buildQueue.run() 對資源從入口遞歸開始打包
PromiseQueue
#PromiseQueue
ShQueue
PromiseQueue
async processAsset(asset, isRebuild) { if (isRebuild) { asset.invalidate(); if (this.cache) { this.cache.invalidate(asset.name); } } await this.loadAsset(asset); }
async loadAsset(asset) { if (asset.processed) { return; } // Mark the asset processed so we don't load it twice asset.processed = true; // 先尝试读缓存,缓存没有在后台加载和编译 asset.startTime = Date.now(); let processed = this.cache && (await this.cache.read(asset.name)); let cacheMiss = false; if (!processed || asset.shouldInvalidate(processed.cacheData)) { processed = await this.farm.run(asset.name); cacheMiss = true; } asset.endTime = Date.now(); asset.buildTime = asset.endTime - asset.startTime; asset.id = processed.id; asset.generated = processed.generated; asset.hash = processed.hash; asset.cacheData = processed.cacheData; // 解析和加载当前资源的依赖项 let assetDeps = await Promise.all( dependencies.map(async dep => { dep.parent = asset.name; let assetDep = await this.resolveDep(asset, dep); if (assetDep) { await this.loadAsset(assetDep); } return assetDep; }) ); if (this.cache && cacheMiss) { this.cache.write(asset.name, processed); } }
read 读取缓存,并判断最后修改时间和缓存的修改时间
write 写入缓存
缓存目录为了加速读取,避免将所有的缓存文件放在一个文件夹里,parcel 将 16进制 两位数的 256 种可能创建为文件夹,这样存取缓存文件的时候,将目标文件路径 md5 加密转换为 16进制,然后截取前两位是目录,后面几位是文件名
WorkerFarm
在上面 start 里初始化 farm 的时候,workerPath 指向了 worker.js 文件,worker.js 里有两个函数,init 和 run
WorkerFarm.getShared 初始化的时候会创建一个 new WorkerFarm ,调用 worker.js 的 init 方法,根据 cpu 获取最大的 Worker 数,并启动一半的子进程
farm.run 会通知子进程执行 worker.js 的 run 方法,如果进程数没有达到最大会再次开启一个新的子进程,子进程执行完毕后将 Promise状态更改为完成
worker.run -> pipeline.process -> pipeline.processAsset -> asset.process
Asset.process 处理资源:
async process() { if (!this.generated) { await this.loadIfNeeded(); await this.pretransform(); await this.getDependencies(); await this.transform(); this.generated = await this.generate(); } return this.generated; }
将上面的代码内部扩展一下:
async process() { // 已经有就不需要编译 if (!this.generated) { // 加载代码 if (this.contents == null) { this.contents = await this.load(); } // 可选。在收集依赖之前转换。 await this.pretransform(); // 将代码解析为 AST 树 if (!this.ast) { this.ast = await this.parse(this.contents); } // 收集依赖 await this.collectDependencies(); // 可选。在收集依赖之后转换。 await this.transform(); // 生成代码 this.generated = await this.generate(); } return this.generated; } // 最后处理代码 async postProcess(generated) { return generated }
processAsset 中调用 asset.process 生成 generated 这个generated 不一定是最终代码 ,像 html里内联的 script ,vue 的 html, js, css,都会进行二次或多次递归处理,最终调用 asset.postProcess 生成代码
Asset
下面说几个实现
HTMLAsset:
pretransform 调用 posthtml 将 html 解析为 PostHTMLTree(如果没有设置posthtmlrc之类的不会走)
parse 调用 posthtml-parser 将 html 解析为 PostHTMLTree
collectDependencies 用 walk 遍历 ast,找到 script, img 的 src,link 的 href 等的地址,将其加入到依赖
transform htmlnano 压缩代码
generate 处理内联的 script 和 css
postProcess posthtml-render 生成 html 代码
JSAsset:
pretransform 调用 @babel/core 将 js 解析为 AST,处理 process.env
parse 调用 @babel/parser 将 js 解析为 AST
collectDependencies 用 babylon-walk 遍历 ast, 如 ImportDeclaration,import xx from 'xx' 语法,CallExpression 找到 require调用,import 被标记为 dynamic 动态导入,将这些模块加入到依赖
transform 处理 readFileSync,__dirname, __filename, global等,如果没有设置scopeHoist 并存在 es6 module 就将代码转换为 commonjs,terser 压缩代码
generate @babel/generator 获取 js 与 sourceMap 代码
VueAsset:
parse @vue/component-compiler-utils 与 vue-template-compiler 对 .vue 文件进行解析
generate 对 html, js, css 处理,就像上面说到会对其分别调用 processAsset 进行二次解析
postProcess component-compiler-utils 的 compileTemplate, compileStyle处理 html,css,vue-hot-reload-api HMR处理,压缩代码
回到 bundle 方法:
let loadedAssets = await this.buildQueue.run() 就是上面说到的PromiseQueue 和 WorkerFarm 结合起来:buildQueue.run —> processAsset -> loadAsset -> farm.run -> worker.run -> pipeline.process -> pipeline.processAsset -> asset.process,执行之后所有资源编译完毕,并返回入口资源loadedAssets就是 index.html 对应的 HTMLAsset 资源
之后是 let changedAssets = [...this.findOrphanAssets(), ...loadedAssets] 获取到改变的资源
findOrphanAssets 是从所有资源中查找没有 parentBundle 的资源,也就是独立的资源,这个 parentBundle 会在等会的构建 Bundle 树中被赋值,第一次构建都没有 parentBundle,所以这里会重复入口文件,这里的 findOrphanAssets 的作用是在第一次构建之后,文件change的时候,在这个文件 import了新的一个文件,因为新文件没有被构建过 Bundle 树,所以没有 parentBundle,这个新文件也被标记物 change
invalidateBundle 因为接下来要构建新的树所以调用重置所有资源上一次树的属性
createBundleTree 构建 Bundle 树:
首先一个入口资源会被创建成一个 bundle,然后动态的 import() 会被创建成子 bundle ,这引发了代码的拆分。
当不同类型的文件资源被引入,兄弟 bundle 就会被创建。例如你在 JavaScript 中引入了 CSS 文件,那它会被放置在一个与 JavaScript 文件对应的兄弟 bundle 中。
如果资源被多于一个 bundle 引用,它会被提升到 bundle 树中最近的公共祖先中,这样该资源就不会被多次打包。
Bundle:
type:它包含的资源类型 (例如:js, css, map, ...)
name:bundle 的名称 (使用 entryAsset 的 Asset.generateBundleName() 生成)
parentBundle:父 bundle ,入口 bundle 的父 bundle 是 null
entryAsset:bundle 的入口,用于生成名称(name)和聚拢资源(assets)
assets:bundle 中所有资源的集合(Set)
childBundles:所有子 bundle 的集合(Set)
siblingBundles:所有兄弟 bundle 的集合(Set)
siblingBundlesMap:所有兄弟 bundle 的映射 Map
offsets:所有 bundle 中资源位置的映射 Map
我们的例子会被构建成:
html ( index.html ) |-- js ( index.js, module1.js, module2.js ) |-- map ( index.js, module1.js, module2.js )
module1.js 和 module2.js 被提到了与 index.js 同级,map 因为类型不同被放到了 子bundle
一个复杂点的树:
// 资源树 index.html |-- index.css |-- bg.png |-- index.js |-- module.js
// mainBundle html ( index.html ) |-- js ( index.js, module.js ) |-- map ( index.map, module.map ) |-- css ( index.css ) |-- js ( index.css, css-loader.js bundle-url.js ) |-- map ( css-loader.js, bundle-url.js ) |-- png ( bg.png )
因为要对 css 热更新,所以新增了 css-loader.js, bundle-url.js 两个 js
replaceBundleNames替换引用:生成树之后将代码中的文件引用替换为最终打包的文件名,如果是生产环境会替换为 contentHash 根据内容生成 hash
hmr更新: 判断启用 hmr 并且不是第一次构建的情况,调用 hmr.emitUpdate 将改变的资源发送给浏览器
Bundle.package 打包
unloadOrphanedAssets 将独立的资源删除
package
package 将generated 写入到文件
有6种打包:
CSSPackager,HTMLPackager,SourceMapPackager,JSPackager,JSConcatPackager,RawPackager
当开启 scopeHoist 时用 JSConcatPackager 否则 JSPackager
图片等资源用 RawPackager
最终我们的例子被打包成 index.html, src.[hash].js, src.[hash].map 3个文件
index.html 里的 js 路径被替换成立最终打包的地址
我们看一下打包的 js:
parcelRequire = (function (modules, cache, entry, globalName) { // Save the require from previous bundle to this closure if any var previousRequire = typeof parcelRequire === 'function' && parcelRequire; var nodeRequire = typeof require === 'function' && require; function newRequire(name, jumped) { if (!cache[name]) { localRequire.resolve = resolve; localRequire.cache = {}; var module = cache[name] = new newRequire.Module(name); modules[name][0].call(module.exports, localRequire, module, module.exports, this); } return cache[name].exports; function localRequire(x){ return newRequire(localRequire.resolve(x)); } function resolve(x){ return modules[name][4][x] || x; } } for (var i = 0; i <p>可以看到代码被拼接成了对象的形式,接收参数 module, require 用来模块导入导出,实现了 commonjs 的模块加载机制,一个更加简化版:</p><pre class="brush:php;toolbar:false">parcelRequire = (function (modules, cache, entry, globalName) { function newRequire(id){ if(!cache[id]){ let module = cache[id] = { exports: {} } modules[id][0].call(module.exports, newRequire, module, module.exports, this); } return cache[id] } for (var i = 0; i <p>代码被拼接起来:</p><pre class="brush:php;toolbar:false">`(function(modules){ //...newRequire })({` + asset.id + ':[function(require,module,exports) {\n' + asset.generated.js + '\n},' + '})'
(function(modules){ //...newRequire })({ "src/index.js":[function(require,module,exports){ // code }] })
hmr-runtime
上面打包的 js 中还有个 hmr-runtime.js 太长被我省略了
hmr-runtime.js 创建一个 WebSocket 监听服务端消息
修改文件触发 onChange 方法,onChange 将改变的资源 buildQueue.add 加入构建队列,重新调用 bundle 方法,打包资源,并调用 emitUpdate 通知浏览器更新
当浏览器接收到服务端有新资源更新消息时
新的资源就会设置或覆盖之前的模块
modules[asset.id] = new Function('require', 'module', 'exports', asset.generated.js)
对模块进行更新:
function hmrAccept(id){ // dispose 回调 cached.hot._disposeCallbacks.forEach(function (cb) { cb(bundle.hotData); }); delete bundle.cache[id]; // 删除之前缓存 newRequire(id); // 重新此加载 // accept 回调 cached.hot._acceptCallbacks.forEach(function (cb) { cb(); }); // 递归父模块 进行更新 getParents(global.parcelRequire, id).some(function (id) { return hmrAccept(global.parcelRequire, id); }); }
至此整个打包流程结束
总结
parcle index.html
进入 cli,启动Server调用 bundle,初始化配置(Plugins, env, HMRServer, Watcher, WorkerFarm),从入口资源开始,递归编译(babel, posthtml, postcss, vue-template-compiler等),编译完设置缓存,构建 Bundle 树,进行打包
如果没有 watch 监听,结束关闭 Watcher, Worker, HMR
有 watch 监听:
文件修改,触发 onChange,将修改的资源加入构建队列,递归编译,查找缓存(这一步缓存的作用就提醒出来了),编译完设置新缓存,构建 Bundle 树,进行打包,将 change 的资源发送给浏览器,浏览器接收 hmr 更新资源
以上是Parcel原始碼的詳細分析(附範例)的詳細內容。更多資訊請關注PHP中文網其他相關文章!