從最簡單的例子入手分析 PixiJS 原始碼
我一般是以使用角度作為切入點檢視分析原始碼,例子中用到什麼類,什麼方法,再入原始碼。
高屋建瓴的角度咱也做不到啊,畢竟水平有限
pixijs 的原始碼之前折騰了半天都執行不起來,文件也沒有明確說明如何調式
我在 github 上看到過也有歪果仁在問如何本地調式最後他放棄了轉用了別的庫...
還有就是 npm 在我們迷之大陸確實不太友好
原始碼 pixijs 7.3.2 版下載地址 https://github.com/pixijs/pixijs/tree/v7.3.2
本地調式環境說明
npm 8.19.2
Node.js v16.18.0
安裝命令
npm install
執行命令
npm start
serve 靜態伺服器全域性安裝
https://www.npmjs.com/package/serve
原始碼目錄結構
- 根目錄
- bundles 打包後原始碼
- examples 例子
- packages 原始碼
- scripts 工程指令碼
- test 測試目錄 (我們用不到)
- tools 服務於測試的工具目錄 (我們用不到)
專案原始碼根目錄下有個主包的 package.json name 是 pixi.js-monorepo
從名字可以看出來,這個專案是用 monorepo 方式來組織管理程式碼的
在 rollup.config.mjs 配置檔案內配置有一個方法:
await workspacesRun.default({ cwd: process.cwd(), orderByDeps: true }, async (pkg) =>
{
if (!pkg.config.private)
{
packages.push(pkg);
}
});
主要作用就是遍歷所有子專案,將非私有專案加入到 'packages' 陣列變數中,然後分析依賴關係再打包輸出
PixiJS 原始碼在 packages 目錄
/packages 目錄下每一個 "大類" 模組都是單獨的專案
每一個 "大類" 都有自己單獨 package.json 檔案, 在 package.json 檔案內指定自己的依賴
比如 app 模組的 package.json 檔案內指定了依賴:
"peerDependencies": {
"@pixi/core": "file:../core",
"@pixi/display": "file:../display"
}
其中的 src 就是此"大類"原始碼目錄,與 src 同級的 test 是此"大類"的測試用例
調式過程中我發現編譯真的挺慢的 ...
調式步驟
為了調式大致需要以下幾步
- npm install 安裝依賴包
- npm start 將原始碼執行起來
- 我就將調式用的 html 網頁放到 example 資料夾下
- 在 html 檔案中引用
<script src="/bundles/pixi.js/dist/pixi.js"></script>
- terminal 在根目錄起一個 serve 靜態服務
serve .
- 瀏覽器訪問靜態服務跳轉到 example 目錄下的具體 html 例子中
完成以上步驟後,你就可以在 /packages 目錄下的任意原始碼內新增 console.log 或 debugger 進行原始碼調式了
相信上面步驟最大的挑戰是
npm install
T_T!
嘗試第一個原始碼調式
原始碼中新增一個 console.log 看看能不能成功輸出先
測試的 example/simple.html 檔案如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title> 最簡單的例子 </title>
<style type="text/css">
*{
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<script src="/bundles/pixi.js/dist/pixi.js"></script>
<script type="text/javascript">
const app = new PIXI.Application({ width: 800, height: 600 });
document.body.appendChild(app.view);
const rectangle = PIXI.Sprite.from('logo.png');
rectangle.x = 100;
rectangle.y = 100;
rectangle.anchor.set(0.5);
rectangle.rotation = Math.PI / 4;
app.stage.addChild(rectangle);
app.ticker.add(() => {
rectangle.rotation += 0.01;
});
</script>
</body>
</html>
以上例子中實現的功能:
- simple.html 首先中引入 pixi.js 檔案
- 透過 new PIXI.Application 建一個 800*800 的畫布例項 app
- 利用 PIXI.Sprite.from 方法引入 logo.png 圖片例項 rectangle
- 為 rectangle 設定座標、anchor、旋轉角度
- 透過 app.stage.addChild 將 rectangle新增到舞臺上
- 在 app.ticker 定時器內新增一個回撥用定時更新旋轉
如果你在本地伺服器環境下開啟 simple.html 檔案,你將會看到一個旋轉的 logo.png
這裡用到了二個類 Application、Sprite
Application 類是 PixiJS 的入口類在 /packages/app/src/Application.ts
原始碼中已說明這個類是建立 PixiJS 應用的便捷類,這個類會自動建立 renderer, ticker 和 root container
Application.ts 原始碼的 constructor 構造方法內新增個 console.log 試試能不能成功輸出
Application.ts 71-85 行
constructor(options)
{
// The default options
options = Object.assign({
forceCanvas: false,
}, options);
this.renderer = autoDetectRenderer(options);
console.log('hello', 88888);
// install plugins here
Application._plugins.forEach((plugin) =>
{
plugin.init.call(this, options);
});
}
移除掉 typescript 型別的原始碼果然看起來眉清目秀一些
成功的關鍵要注意兩點 :
-
先 npm start 專案, 作用是 watch 原始碼變化自動化編譯到 bundles 目錄
-
確保你是在本地伺服器環境下開啟網頁就像這樣訪問
http://localhost:3000/examples/simple
開啟網頁調式器如果輸出 hello 88888
就說明成功可以調式原始碼了
Amazing!
Application 的構造方法就做了兩件事,建立渲染器 (renderer) 和 初始化外掛 (plugin)
renderer 是 PixiJS 的渲染器,渲染器會根據瀏覽器環境自動選擇渲染方式,如 WebGL、Canvas
_plugins 靜態屬性是一個用於存放外掛陣列
Application 類本身的其它主要屬性:
- stage 主要用於新增子顯示物件
- renderer 渲染器
- view canvas dom 渲染 元素引用
- screen 螢幕資訊,更準確的說應該是畫布資訊,x,y,width,height
在例子程式碼中 app.ticker
ticker 物件即是 /packages/ticker/TickerPlugin.ts
"定時器" 外掛, 後面會深入其原始碼細節
autoDetectRenderer
autoDetectRenderer 用於自動判斷使用哪種方式渲染,如 WebGL、Canvas
/packages/core/src/autoDetectRenderer.ts
第 41-52 行
export function autoDetectRenderer<VIEW extends ICanvas = ICanvas>(options?: Partial<IRendererOptionsAuto>): IRenderer<VIEW>
{
for (const RendererType of renderers)
{
if (RendererType.test(options))
{
return new RendererType(options) as IRenderer<VIEW>;
}
}
throw new Error('Unable to auto-detect a suitable renderer.');
}
顯然, 透過迴圈檢測所有的 renderers 渲染器型別 與建構函式傳遞過來的 options 引數進行檢測返回符合條件的渲染器
RendererType.test 就是渲染器的一個檢測方法
而 renderers 陣列就定義在了第 29 -32 行
const renderers: IRendererConstructor<ICanvas>[] = [];
extensions.handleByList(ExtensionType.Renderer, renderers);
這裡又用到了一個叫 extensions
的全域性物件,這個全域性物件顧名思議,就是用來管理所有擴充套件外掛的,嗯,所以渲染器也是一個 extension
extensions 擴充套件外掛簡介
擴充套件外掛原始碼檔案 /packages/extensions/src/index.ts
官方的外掛的型別有這些:
'renderer'
'application'
'renderer-webgl-system'
'renderer-webgl-plugin'
'renderer-canvas-system'
'renderer-canvas-plugin'
'asset'
'load-parser'
'resolve-parser'
'cache-parser'
'detection-parser
具體外掛類或物件都是註冊到對應的型別下的
類先透過 extensions 全域性物件的 handleByList
或 handleByMap
方法註冊外掛型別
當真正新增外掛時,呼叫的是 extensions 全域性物件的 add
方法外掛就會新增到對應的外掛型別下
比如 TikerPlugin.ts ResizePlugin.ts 就是註冊到了 'application' 型別下
又比如 load 相關的外掛就註冊到了 'load-parser' 型別下
最後具體的外掛是註冊到具體類的 _plugins 屬性上比如: Application._plugins
在 /packages/extensions/src/index.ts
檔案中第 240-265 行,找到 handleByList
方法
在 extensions/index.ts 244 行加個 console.log 列印一下:
handleByList(type: ExtensionType, list: any[], defaultPriority = -1)
{
return this.handle(
type,
(extension) =>
{
if (list.includes(extension.ref))
{
return;
}
console.log(extension.ref);
list.push(extension.ref);
list.sort((a, b) => normalizePriority(b, defaultPriority) - normalizePriority(a, defaultPriority));
},
(extension) =>
{
const index = list.indexOf(extension.ref);
if (index !== -1)
{
list.splice(index, 1);
}
}
);
},
輸出:
圖 1-1
可以看到輸出了一堆 class 和 物件 (實現了 ExtensionFormat "介面" 的物件), 只知道有這些,現在還不知道具體幹啥
把 handleByList 方法的 type 和 list 也列印出來看看
圖 2-2
可以看到每個外掛型別都可以擁有多個 extention 陣列
再看看它的 add 方法
在 extensions/index.ts 152 - 175 行
add(...extensions: Array<ExtensionFormatLoose | any>)
{
extensions.map(normalizeExtension).forEach((ext) =>
{
ext.type.forEach((type) =>
{
const handlers = this._addHandlers;
const queue = this._queue;
// 如果新增的外掛還沒有外掛型別,就放到 _queue 記憶體起來
if (!handlers[type])
{
queue[type] = queue[type] || [];
queue[type].push(ext);
}
else
{
// 如果已經有相應的外掛型別了,就新增到對應外掛型別下
handlers[type](ext);
}
});
});
return this;
},
可以看到它接收一個外掛陣列物件 'extensions' 將傳進來的物件進行 '外掛物件標準化'後,該物件擁有 type, name, priority, ref 這些屬性
interface ExtensionFormatLoose
{
type: ExtensionType | ExtensionType[];
name?: string;
priority?: number;
ref: any;
}
解耦與注入外掛
PixiJS 這種外掛方式的設計就是為了解耦,方便管理和擴充套件更多外掛
邏輯如下:
-
Application.ts 在全域性 extensions 物件中註冊外掛型別並傳入用於儲存外掛的陣列
extensions.handleByList(ExtensionType.Application, Application._plugins);
-
TickerPlugin.ts 在 extensions 注入至對應的 Application 型別外掛陣列
extensions.add(TickerPlugin);
-
Application.ts 在例項化時會它所有外掛的 init 方法,將外掛也“例項化”
-
其它外掛或自定義外掛實現註冊與呼叫同樣適用,不需要再進入 Application.ts 修改邏輯實現解耦
我們以 /packages/ticker/TickerPlugin.ts
時鐘外掛舉例
在 tickerPlugin.ts 檔案的最後一行有一句 extensions.add(TickerPlugin);
這一句就是將 TickerPlugin 物件新增到了Application 類的 _plugins 陣列
TickerPlugin.ts 35 行標明瞭這個擴充套件屬於 Application 類
static extension: ExtensionMetadata = ExtensionType.Application;
仔細觀察 TickerPlugin.ts 檔案,發現它並沒有 constructor 建構函式
而是有一個公開的 init
函式,這個函式就是外掛的入口函式,它會被 Application 建構函式呼叫並將 this 指向了 Application 物件本身
所以在 init
函式內訪問的 this 就是 Application 物件本身
Ticker
我們都知道與瀏覽器的自動更新渲染方式不同,在 canvas 更新渲染畫面都是透過手動擦掉舊的畫素重新繪製新畫素實現的
時鐘外掛很大一部分工作就是用於管理渲染更新的,它屬於 Application 類的擴充套件外掛.
在 TickerPlugin.ts 的 init 方法內,檔案第 115 行
this.ticker = options.sharedTicker ? Ticker.shared : new Ticker();
即說明例項化 Application 後自動建立了一個 Ticker 例項, sharedTicker 看名字就知道是個共享的時鐘
共有三種 ticker: sharedTicker, systemTicker, 普通 ticker
只要 this.ticker 被賦值,舊的 Application render 方法會刪除並新增一個新的 render 回撥進入 ticker 佇列, 還有個 UPDATE_PRIORITY.LOW 用來管理回撥佇列的優先順序
TickerPlugin.ts 的 init 方法內,檔案第 57 - 75 行:
Object.defineProperty(this, 'ticker',
{
set(ticker)
{
if (this._ticker)
{
this._ticker.remove(this.render, this);
}
this._ticker = ticker;
if (ticker)
{
ticker.add(this.render, this, UPDATE_PRIORITY.LOW);
}
},
get()
{
return this._ticker;
},
});
讓我們進入 Ticker.ts 類看看
渲染相關的回撥透過 Ticker.add 和 Ticker.addOnce 新增加到 Ticker 類中
顧名思義 addOnce 就是一次性的回撥,我們只要理解 add 方法就可以了
Ticker.ts 198 - 201 行:
add<T = any>(fn: TickerCallback<T>, context?: T, priority = UPDATE_PRIORITY.NORMAL): this
{
return this._addListener(new TickerListener(fn, context, priority));
}
渲染回撥還用 TickerListener.ts 類,包裝了一下,包裝的主要目的是將相應的渲染回撥函式根據 priority 權重組成一個回撥 “連結串列佇列”
priority 權重在 /packages/ticker/const.ts
定義
TickerListener.ts 類主要的兩個方法: emit 觸發函式和 connect 連線函式
/packages/ticker/TickerListener.ts
97 - 106 行 connect 函式:
connect(previous: TickerListener): void
{
this.previous = previous;
if (previous.next)
{
previous.next.previous = this;
}
this.next = previous.next;
previous.next = this;
}
得結合 Ticker 類的 _addListener 一起看:
/packages/ticker/Ticker.ts
223 - 258 行:
private _addListener(listener: TickerListener): this
{
// For attaching to head
let current = this._head.next;
let previous = this._head;
// 如果還沒有添過,就新增到 _head 後面
if (!current)
{
listener.connect(previous);
}
else
{
// priority 優先順序從最高到最低
while (current)
{
if (listener.priority > current.priority)
{
listener.connect(previous);
break;
}
previous = current;
current = current.next;
}
// 如果還沒有加入到連結串列中,則加入到連結串列尾部
if (!listener.previous)
{
listener.connect(previous);
}
}
this._startIfPossible();
return this;
}
可以看到透過 while 迴圈整個 this._head 儲存的連結串列,根據 priority 權重找到需要插入的位置,然後插入到連結串列中。
如果沒找到位置,則新增到連結串列最後
_addListener 函式最後還呼叫了 _startIfPossible 既而呼叫了 _requestIfNeeded 方法
_requestIfNeeded 即刻發起 this._tick “請求”
private _requestIfNeeded(): void
{
if (this._requestId === null && this._head.next)
{
// ensure callbacks get correct delta
this.lastTime = performance.now();
this._lastFrame = this.lastTime;
this._requestId = requestAnimationFrame(this._tick);
}
}
this._tick
函式定義在 Ticker.ts 的建構函式內
/packages/ticker/Ticker.ts
116 - 137 行
constructor()
{
this._head = new TickerListener(null, null, Infinity);
this.deltaMS = 1 / Ticker.targetFPMS;
this.elapsedMS = 1 / Ticker.targetFPMS;
this._tick = (time: number): void =>
{
this._requestId = null;
if (this.started)
{
// 此處觸發回撥函式,並傳入 delta time
this.update(time);
// 回撥函式執行後可能會影響 ticker狀態,所以需要再次檢查
if (this.started && this._requestId === null && this._head.next)
{
// 繼續執行下一幀
this._requestId = requestAnimationFrame(this._tick);
}
}
};
}
_tick 函式就是每一幀都會執行
this._head 連結串列頭部,為方便處理統一加一個虛擬頭部節點
this.deltaMS 預設為 1/0.06 = 16.66666 重新整理率
this.elaspedMS 幀間隔時間
即使你沒有往畫布中繪製任何圖形,也會執行。不信你可以在 _tick 內新增一個 console.log 看看
當 _tick 觸發時呼叫的就是 update 函式
/packages/ticker/Ticker.ts
369 - 442 行
update(currentTime = performance.now()): void
{
let elapsedMS;
// update 也可由使用者主動觸發
// 如果間隔時間是0或是負數不不需要觸發通知回撥
// currentTime
if (currentTime > this.lastTime)
{
// Save uncapped elapsedMS for measurement
elapsedMS = this.elapsedMS = currentTime - this.lastTime;
// cap the milliseconds elapsed used for deltaTime
if (elapsedMS > this._maxElapsedMS)
{
elapsedMS = this._maxElapsedMS;
}
elapsedMS *= this.speed;
// If not enough time has passed, exit the function.
// Get ready for next frame by setting _lastFrame, but based on _minElapsedMS
// adjustment to ensure a relatively stable interval.
if (this._minElapsedMS)
{
const delta = currentTime - this._lastFrame | 0;
if (delta < this._minElapsedMS)
{
return;
}
this._lastFrame = currentTime - (delta % this._minElapsedMS);
}
this.deltaMS = elapsedMS;
this.deltaTime = this.deltaMS * Ticker.targetFPMS;
// Cache a local reference, in-case ticker is destroyed
// during the emit, we can still check for head.next
const head = this._head;
// Invoke listeners added to internal emitter
let listener = head.next;
while (listener)
{
listener = listener.emit(this.deltaTime);
}
if (!head.next)
{
this._cancelIfNeeded();
}
}
else
{
this.deltaTime = this.deltaMS = this.elapsedMS = 0;
}
this.lastTime = currentTime;
}
額外小知識
對於需要高精度時間戳的動畫或輸入處理,performance.now() 可以提供比 Date.now() 更高的精度。
與 requestAnimationFrame 結合使用:
requestAnimationFrame 的回撥函式接收一個高精度的時間戳作為引數,這個時間戳與 performance.now() 返回的時間戳是同步的。
因此,你可以使用 performance.now() 來與 requestAnimationFrame 回撥中的時間戳進行比較或計算。
需要注意的是,performance.now() 返回的時間戳是相對於某個特定時間點的,而不是絕對的時間(如日期和時間)。因此,它主要用於測量時間間隔,而不是獲取當前的日期和時間。
update 方法主要功能就是判斷當前時間與上一次呼叫的時間差,如果大於最大間隔時間(需要更新一幀時)就執行回撥連結串列
listener.emit(this.deltaTime);
注意 listener.emit() 執行後返回的是下一個回撥函式,即 listener.next 以完成 while 迴圈
PixiJS Ticker 與 EaselJS Ticker 的區別
-
PixiJS Ticker 預設是開啟的,EaselJS Ticker 直到有新增 Ticker 回撥才開啟
-
PixiJS Ticker 可被例項化,有建構函式,而 EaselJS Ticker 更像是一個全域性物件
-
PixiJS Ticker 回撥使用函式採用連結串列方式儲存擁有可調節的權重, EaselJS Ticker 直接使用了 EventDispatcher “標準事件” 方式實現回撥,回撥使用陣列儲存沒有權重可調節
-
PixiJS Ticker 使用 requestAnimationFrame 實現 tick,EaselJS Ticker 庫較早,所以還支援 setTimeout 方式
本章小節
這一章先介紹原始碼如何下載並搭建本地調式環境,然後用一個簡單的例子來列印出調式資訊
以 Appllication 類為入口進入原始碼, 瞭解了 PixiJS 的基本擴充套件外掛機制
最後分析最重要的 Ticker 實現
說實話我在現實前端專案中從未用到過連結串列,很意外在分析PixiJS原始碼的時候居然發現 Ticker 回撥是用連結串列實現的,look! 沒用的知識又增加了!
上面 simple.html 例子中的 PIXI.Sprite 和 app.stage 還沒有進入原始碼, 下一章先嚐試進入 stage 這一部分,如果可以的話 Sprite 也過一遍
注:轉載請註明出處部落格園:王二狗Sheldon池中物 (willian12345@126.com)