Webpack 的插件機制 - Tapable

{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/95\/95ea75bbba67a6dbe4d9e5ca30e96f5c.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"前言"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"用了這麼久的 Webpack,你一定對它的生態重要組成部分"},{"type":"codeinline","content":[{"type":"text","text":"loader"}]},{"type":"text","text":"、"},{"type":"codeinline","content":[{"type":"text","text":"plugin"}]},{"type":"text","text":"很好奇吧,你是否嘗試過編寫自己的插件呢,是否瞭解過 Webpack 的插件機制呢,什麼?沒有,那還不趕緊上車學一波!"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"1、tapable"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Webpack 就像一條生產線,要經過一系列處理流程後才能將源文件轉換成輸出結果。這條生產線上的每個處理流程的職責都是單一的,多個流程之間有存在依賴關係,只有完成當前處理後才能交給下一個流程去處理。插件就像是一個插入到生產線中的一個功能,在特定的時機對生產線上的資源做處理。Webpack 通過 Tapable 來組織這條複雜的生產線。Webpack 在運行過程中會廣播事件,插件只需要監聽它所關心的事件,就能加入到這條生產線中,去改變生產線的運作。Webpack 的事件流機制保證了插件的有序性,使得整個系統擴展性很好。——「深入淺出 Webpack」"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"作爲 Webpack 的核心庫,"},{"type":"codeinline","content":[{"type":"text","text":"tabpable"}]},{"type":"text","text":"承包了 Webpack 最重要的事件工作機制,包括 Webpack 源碼中高頻的兩大對象("},{"type":"codeinline","content":[{"type":"text","text":"compiler"}]},{"type":"text","text":"、"},{"type":"codeinline","content":[{"type":"text","text":"compilation"}]},{"type":"text","text":")都是繼承自"},{"type":"codeinline","content":[{"type":"text","text":"Tapable"}]},{"type":"text","text":"類的對象,這些對象都擁有"},{"type":"codeinline","content":[{"type":"text","text":"Tapable"}]},{"type":"text","text":"的註冊和調用插件的功能,並向外暴露出各自的執行順序以及"},{"type":"codeinline","content":[{"type":"text","text":"hook"}]},{"type":"text","text":"類型,詳情可見文檔"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"2、tapable 的鉤子"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"const {\n SyncHook,\n SyncBailHook,\n SyncWaterfallHook,\n SyncLoopHook,\n AsyncParallelHook,\n AsyncParallelBailHook,\n AsyncSeriesHook,\n AsyncSeriesBailHook,\n AsyncSeriesWaterfallHook\n } = require(\"tapable\");\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上面是官方文檔給出的 9 種鉤子的類型,我們看命名就能大致推測他們的類型和區別,分成同步、異步,瀑布流、串行、並行類型、循環類型等等,鉤子的目的是爲了顯式地聲明,觸發監聽事件時(call)傳入的參數,以及訂閱該鉤子的 callback 函數所接受到的參數,舉個最簡單的🌰"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"const sync = new SyncHook(['arg']) \/\/ 'arg' 爲參數佔位符\nsync.tap('Test', (arg1, arg2) => {\n console.log(arg1, arg2) \/\/ a,undefined\n})\nsync.call('a', '2')"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上述代碼定義了一個同步串行鉤子,並聲明瞭接收的參數的個數,可以通過"},{"type":"codeinline","content":[{"type":"text","text":"hook"}]},{"type":"text","text":"實例對象("},{"type":"codeinline","content":[{"type":"text","text":"SyncHook"}]},{"type":"text","text":"本身也是繼承自"},{"type":"codeinline","content":[{"type":"text","text":"Hook"}]},{"type":"text","text":"類的)的"},{"type":"codeinline","content":[{"type":"text","text":"tap"}]},{"type":"text","text":"方法訂閱事件,然後利用"},{"type":"codeinline","content":[{"type":"text","text":"call"}]},{"type":"text","text":"函數觸發訂閱事件,執行 callback 函數,值得注意的是 call 傳入參數的數量需要與實例化時傳遞給鉤子類構造函數的數組長度保持一致,否則,即使傳入了多個,也只能接收到實例化時定義的參數個數。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"embedcomp","attrs":{"type":"table","data":{"content":"
序號鉤子名稱執行方式使用要點
1SyncHook同步串行不關心監聽函數的返回值
2SyncBailHook同步串行只要監聽函數中有一個函數的返回值不爲 null,則跳過剩餘邏輯
3SyncWaterfallHook同步串行上一個監聽函數的返回值將作爲參數傳遞給下一個監聽函數
4SyncLoopHook同步串行當監聽函數被觸發的時候,如果該監聽函數返回 true 時則這個監聽函數會反覆執行,如果返回 undefined 則表示退出循環
5AsyncParallelHook異步並行不關心監聽函數的返回值
6AsyncParallelBailHook異步並行只要監聽函數的返回值不爲 null,就會忽略後面的監聽函數執行,直接跳躍到 callAsync 等觸發函數綁定的回調函數,然後執行這個被綁定的回調函數
7AsyncSeriesHook異步串行不關心 callback()的參數
8AsyncSeriesBailHook異步串行callback()的參數不爲 null,就會直接執行 callAsync 等觸發函數綁定的回調函數
9AsyncSeriesWaterfallHook異步串行上一個監聽函數的中的 callback(err, data)的第二個參數,可以作爲下一個監聽函數的參數"}}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上述表格羅列了所有 hook 的使用方式和要點。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"3、註冊事件回調"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"註冊事件回調有三個方法:"},{"type":"codeinline","content":[{"type":"text","text":"tap"}]},{"type":"text","text":"、"},{"type":"codeinline","content":[{"type":"text","text":"tapAsync"}]},{"type":"text","text":" 和 "},{"type":"codeinline","content":[{"type":"text","text":"tapPromise"}]},{"type":"text","text":",其中 "},{"type":"codeinline","content":[{"type":"text","text":"tapAsync"}]},{"type":"text","text":" 和 "},{"type":"codeinline","content":[{"type":"text","text":"tapPromise"}]},{"type":"text","text":" 不能用於 "},{"type":"codeinline","content":[{"type":"text","text":"Sync"}]},{"type":"text","text":" 開頭的鉤子類,強行使用會報錯。"},{"type":"codeinline","content":[{"type":"text","text":"tap"}]},{"type":"text","text":"的使用方式在上文已經展示過了,就用官方文檔的例子展示下"},{"type":"codeinline","content":[{"type":"text","text":"tapAsync"}]},{"type":"text","text":"的使用方式,相比於"},{"type":"codeinline","content":[{"type":"text","text":"tap"}]},{"type":"text","text":","},{"type":"codeinline","content":[{"type":"text","text":"tapAsync"}]},{"type":"text","text":"需要執行 callback 函數才能確保流程會走到下一個插件中去。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"myCar.hooks.calculateRoutes.tapAsync(\"BingMapsPlugin\", (source, target, routesList, callback) => {\n bing.findRoute(source, target, (err, route) => {\n if(err) return callback(err);\n routesList.add(route);\n \/\/ call the callback\n callback();\n });\n});"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"4、觸發事件"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"觸發事件的三個方法是與註冊事件回調的方法一一對應的,這點從方法的名字上也能看出來:"},{"type":"codeinline","content":[{"type":"text","text":"call"}]},{"type":"text","text":" 對應 "},{"type":"codeinline","content":[{"type":"text","text":"tap"}]},{"type":"text","text":"、"},{"type":"codeinline","content":[{"type":"text","text":"callAsync"}]},{"type":"text","text":" 對應 "},{"type":"codeinline","content":[{"type":"text","text":"tapAsync"}]},{"type":"text","text":" 和 "},{"type":"codeinline","content":[{"type":"text","text":"promise"}]},{"type":"text","text":" 對應 "},{"type":"codeinline","content":[{"type":"text","text":"tapPromise"}]},{"type":"text","text":"。一般來說,我們註冊事件回調時用了什麼方法,觸發時最好也使用對應的方法。同樣需要注意的是 callAsync 有個 callback 函數,在邏輯完畢時需要執行,一些具體用法類似於上面的註冊事件類似,就不一一展開了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"5、瞭解機制"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"那麼在 Webpack 中到底如何使用 tapable 調用這些 plugin 呢?"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"我們首先來看官網給出的編寫一個 plugin 的示例"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"class HelloWorldPlugin {\n  apply(compiler) {\n    compiler.hooks.done.tap('Hello World Plugin', (\n      compilation \/* compilation is passed as an argument when done hook is tapped.  *\/\n    ) => {\n      console.log('Hello World!');\n    });\n  }\n}\n\nmodule.exports = HelloWorldPlugin;\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上述代碼塊編寫了一個叫 HelloWorldPlugin 的類,它提供了一個叫"},{"type":"codeinline","content":[{"type":"text","text":"apply"}]},{"type":"text","text":"的方法,在該方法中我們可以從外部獲取到 Webpack 執行全過程中單一的"},{"type":"codeinline","content":[{"type":"text","text":"compiler"}]},{"type":"text","text":"實例,通過"},{"type":"codeinline","content":[{"type":"text","text":"compiler"}]},{"type":"text","text":"實例,我們可以在 Webpack 的生命週期的"},{"type":"codeinline","content":[{"type":"text","text":"done"}]},{"type":"text","text":"節點(也就是上面我們提到的"},{"type":"codeinline","content":[{"type":"text","text":"hook"}]},{"type":"text","text":")tap 一個監聽事件,也就是說當 Webpack 全部流程執行完畢時,監聽事件將會被觸發,同時"},{"type":"codeinline","content":[{"type":"text","text":"stat"}]},{"type":"text","text":"統計信息會被傳入到監聽事件中,在事件中,我們就可以通過"},{"type":"codeinline","content":[{"type":"text","text":"stat"}]},{"type":"text","text":"做一系列我們想要做的數據分析。一般來說,使用一個 Webpack 插件,需要在 Webpack 配置文件中導入("},{"type":"codeinline","content":[{"type":"text","text":"import"}]},{"type":"text","text":")插件的類,new 一個實例,like this:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"\/\/ Webpack.config.js\nvar HelloWorldPlugin = require('hello-world');\n\nmodule.exports = {\n \/\/ ... configuration settings here ...\n plugins: [new HelloWorldPlugin({ options: true })]\n};"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這裏聰明的你一定想到了 Webpack 應該是讀取了這份配置文件後獲得了"},{"type":"codeinline","content":[{"type":"text","text":"HelloWorldPlugin"}]},{"type":"text","text":"實例,並調用了實例的"},{"type":"codeinline","content":[{"type":"text","text":"apply"}]},{"type":"text","text":"方法,在"},{"type":"codeinline","content":[{"type":"text","text":"done"}]},{"type":"text","text":"節點上添加了監聽事件!沒錯,讓我們來追溯下 Webpack 的源碼部分,在 Webpack 項目的"},{"type":"codeinline","content":[{"type":"text","text":"lib\/Webpack.js"}]},{"type":"text","text":"文件中,我們可以看到"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"if (options.plugins && Array.isArray(options.plugins)) {\n    for (const plugin of options.plugins) {\n  if (typeof plugin === \"function\") {\n   plugin.call(compiler, compiler);\n  } else {\n   plugin.apply(compiler);\n  }\n }\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這段代碼中"},{"type":"codeinline","content":[{"type":"text","text":"options"}]},{"type":"text","text":"就是指配置文件導出的整個對象,這裏可以看到 Webpack 循環遍歷了一遍 plugins,並分別調用了他們的 apply 方法,當然如果 plugin 是"},{"type":"codeinline","content":[{"type":"text","text":"function"}]},{"type":"text","text":"類型,就直接用"},{"type":"codeinline","content":[{"type":"text","text":"call"}]},{"type":"text","text":"來執行,這也就是我上文提到的一般來說的例外,如果你的插件邏輯很簡單,你可以直接在配置文件裏寫一個"},{"type":"codeinline","content":[{"type":"text","text":"function"}]},{"type":"text","text":",去執行你的邏輯,而不必囉嗦的寫一個類或者用更純粹的"},{"type":"codeinline","content":[{"type":"text","text":"prototype"}]},{"type":"text","text":"去定義類的方法。到這裏爲止,我們已經瞭解了插件中的監聽事件是如何註冊到 Webpack 的"},{"type":"codeinline","content":[{"type":"text","text":"compile"}]},{"type":"text","text":"、"},{"type":"codeinline","content":[{"type":"text","text":"compilation"}]},{"type":"text","text":"("},{"type":"codeinline","content":[{"type":"text","text":"tapable"}]},{"type":"text","text":"類)上去的,那監聽事件是如何、何時被觸發的呢,理論上應該是先註冊完畢,後觸發,這樣監聽事件纔有意義,我們接着發現,在"},{"type":"codeinline","content":[{"type":"text","text":"lib\/Compiler.js"}]},{"type":"text","text":"中的"},{"type":"codeinline","content":[{"type":"text","text":"Compiler"}]},{"type":"text","text":"類的"},{"type":"codeinline","content":[{"type":"text","text":"run"}]},{"type":"text","text":"函數裏有這樣一段代碼"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"typescript"},"content":[{"type":"text","text":"const onCompiled = (err, compilation) => {\n if (err) return finalCallback(err);\n\n if (this.hooks.shouldEmit.call(compilation) === false) {\n ...\n this.hooks.done.callAsync(stats, err => {\n if (err) return finalCallback(err);\n return finalCallback(null, stats);\n });\n return;\n }\n\n this.emitAssets(compilation, err => {\n if (err) return finalCallback(err);\n\n if (compilation.hooks.needAdditionalPass.call()) {\n ...\n this.hooks.done.callAsync(stats, err => {\n ...\n });\n return;\n }\n\n this.emitRecords(err => {\n if (err) return finalCallback(err);\n\n ...\n this.hooks.done.callAsync(stats, err => {\n if (err) return finalCallback(err);\n return finalCallback(null, stats);\n });\n });\n });\n};\n...\n this.compile(onCompiled);"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"回調函數"},{"type":"codeinline","content":[{"type":"text","text":"onCompiled"}]},{"type":"text","text":"會在"},{"type":"codeinline","content":[{"type":"text","text":"compile"}]},{"type":"text","text":"過程結束時被調用,無論走到哪個 if 邏輯中,"},{"type":"codeinline","content":[{"type":"text","text":"this.hooks.done.callAsync"}]},{"type":"text","text":"都會被執行,也就是說在 done 節點上註冊的監聽事件會按照順序依次被觸發執行。接着我們再向上追溯,包裹了"},{"type":"codeinline","content":[{"type":"text","text":"onCompiled"}]},{"type":"text","text":"函數的"},{"type":"codeinline","content":[{"type":"text","text":"run"}]},{"type":"text","text":"函數是在"},{"type":"codeinline","content":[{"type":"text","text":"lib\/Webpack.js"}]},{"type":"text","text":"中被執行的"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"if (Array.isArray(options)) {\n    ...\n} else if (typeof options === \"object\") {\n    ...\n compiler = new Compiler(options.context);\n compiler.options = options;\n if (options.plugins && Array.isArray(options.plugins)) {\n  for (const plugin of options.plugins) {\n   if (typeof plugin === \"function\") {\n    plugin.call(compiler, compiler);\n   } else {\n    plugin.apply(compiler);\n   }\n  }\n }\n} else {\n ...\n}\nif (callback) {\n ...\n compiler.run(callback);\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"剛好在"},{"type":"codeinline","content":[{"type":"text","text":"plugin.apply()"}]},{"type":"text","text":"的後面,所以是符合先註冊監聽事件,再觸發的邏輯順序的。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/c4\/c4c2beb86e9d99117a8be6b2330436db.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"是不是已經有點亂了,來來來,我們用流程圖簡單捋一下。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"插件的註冊執行流程圖示"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/be\/beedda50afcc591dace5faed127b72b4.png","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"6、總結"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"tapable 作爲 Webpack 的核心庫,承接了 Webpack 最重要的事件流的運轉,它巧妙的鉤子設計很好的將實現與流程解耦開來,真正實現了插拔式的功能模塊,在 Webpack 中最核心的負責編譯的 Compiler 和負責創建的 bundles 的 Compilation 都是 Tapable 的實例,可以說想要真正讀懂 Webpack,tapable 的知識儲備是必不可少的,它的一些設計思想也是很值得我們借鑑的,本文只是對 tapable 的一些 api 以及 Webpack 如何使用 tapable 串起了整個插件流工作機制做了介紹。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"horizontalrule"},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"頭圖:Unsplash"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"作者:丁楠"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"原文:https:\/\/mp.weixin.qq.com\/s\/qWq46-7EJb0Byo1H3SDHCg"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"原文:Webpack 的插件機制 - Tapable"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"來源:微醫大前端技術 - 微信公衆號 [ID:wed_fed]"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"轉載:著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。"}]}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章