|
パッケージ化ツール
一般的に使用されるパッケージ化ツールの一般的な機能:
モジュール化 (コード分割、マージ、ツリーシェイキングなど) コンパイル (es6、7、8 sass typescript) 、など) 圧縮 (画像圧縮を含む js、css、html) HMR (ホット リプレースメント)
version
parcel-bundler バージョン:
"バージョン": "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 の第一級市民ではありません。Parcel は、エントリ ファイルから開始してこれらのファイルとモジュールの依存関係を自動的に分析し、バンドル ツリーを構築し、パッケージ化して、指定されたディレクトリに出力します。
A簡単な例
内部ソース コードとパーセルのプロセスを理解するために簡単な例から始めます
index.html
|-- index.js
|-- module1.js
|-- module2.js
上記は例の構造で、入り口はインデックス内のindex.htmlです。使用する HTML スクリプト タグは src/index.js を参照します。index.js では、2 つのサブモジュール
execution
npx パーセルindex.html または ./node_modules/.bin/ を導入します。パーセルインデックス.html、または npm script
cli
"bin": {
"parcel": "bin/cli.js"
}
を使用してパーセルバンドラーの package.json を表示し、bin/cli.js を見つけ、../src/cli# をポイントします。 # cli.js の #
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 のバンドル メソッドを呼び出しますバンドル関数を呼び出すには、serve、watch、build の 3 つのコマンドがあります。 .html、デフォルトはserveであるため、バンドラーは.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を呼び出して作成します最終的なパッケージ化されたフォルダーを指す静的サービス以下これは重要なバンドル メソッドです
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;
}
これを見てみましょう。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 を 1 回だけ実行します。 package.json ファイルの依存関係、devDependency で、package-plugin- を呼び出し、loadEnv を呼び出して環境変数をロードします。dotenv を使用します。dotenv-expand パッケージは、env.development.local、.env.development、.env.local、.env を拡張します。 process.envwatch は、リスニング ファイルを初期化し、変更コールバック関数をバインドします。内部的には、child_process.fork は子プロセスを開始し、chokidar パッケージを使用します。ファイルの変更を監視するために、hmr はサービスを開始し、WebSocket は変更されたリソース ファームをブラウザに送信し、初期化します複数のプロセスを指定し、ワーカー作業ファイルを指定し、リソースを解析してコンパイルするために複数の child_processes を開きます。 次に、バンドルに戻り、isInitialBundle が初めてビルドされるかどうかを判断します。出力フォルダー resolveAsset を介してエントリー ファイルを走査し、内部でリゾルバーを呼び出してパスを解析し、getAsset を呼び出して対応するアセットを取得します (ここでのエントリーは、拡張機能が HTMLAsset を取得するに従って、index.html です)
Addアセットをキューに追加します
次に this.buildQueue.run() を開始し、入口から再帰的にリソースのパッケージ化を開始します
PromiseQueue
ここ buildQueue これは PromiseQueue 非同期キューです
PromiseQueue は、初期化中にコールバック関数 callback を渡し、パラメータ キュー キューを内部で維持し、追加でパラメータをキューにプッシュし、callback(...queue.shift( )) の実行中にキューを走査します。キューはすべて実行され、 Promise は completed (解決済み) に設定されます (Promise.all と理解できます) ここで定義されているコールバック関数は processAsset で、パラメータはエントリ ファイルの HTMLAsset ですindex.html
async processAsset(asset, isRebuild) {
if (isRebuild) {
asset.invalidate();
if (this.cache) {
this.cache.invalidate(asset.name);
}
}
await this.loadAsset(asset);
}
processAsset関数では、まず、それがリビルドであるか、最初のビルドであるか、ファイルの変更を監視することによって実行されたリビルドであるかを判断し、リビルドである場合は、リソースのプロパティをリセットし、そのキャッシュを無効にします。リソースをロードしてコンパイルするためのloadAsset ResourceloadAsset
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);
}
}
loadAssetはコンパイルの繰り返しを防ぐために最初に判定してからキャッシュを読み込みます読み込みに失敗した場合はこれを呼び出します。 farm.run を使用して複数のプロセスでリソースをコンパイルします
コンパイル後、依存ファイルをロードしてコンパイルします最後に、新しいリソースがキャッシュを使用しない場合は、キャッシュをリセットします
関連する 2 つのことについて説明しますここ: キャッシュ FSCache とマルチプロセス WorkerFarm
FSCache
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 ,用于生成准确的 sourcemap 。
我们的例子会被构建成:
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 更新资源