如何用 typescript 寫一個處理 console 的 babel 插件

{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"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":"通過這篇文章你可以學到:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"ts-mocha 和 chai 來寫測試用例"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如何寫一個 babel 插件"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如何用 schame-utils 來做 options 校驗"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"typescript 雙重斷言的一個應用場景"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"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}},{"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":"console 對象對前端工程師來說是必不可少的 api,開發時我們經常通過它來打印一些信息來調試。但生產環境下 console 有時會引起一些問題。"}]},{"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":"最近公司內報了一個 bug,console 對象被重寫了但是沒有把所有的方法都重寫,導致了報錯,另外考慮到 console 會影響性能,所以最後定的解決方案是把源碼中所有的 console 都刪掉。"}]},{"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":"生產環境下刪除 console 是沒問題的,但是這件事不需要手動去做。在打包過程中,我們會對代碼進行壓縮,而壓縮的工具都提供了刪除一些函數的功能,比如 terser 支持 dropconsole 來刪除 console.*,也可以通過 purefuncs 來刪除某幾種 console 的方法。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/e7\/e7864caa7cd4bd9f7a8059a51d716532.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}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"但是這種方案對我們是不適用的,因爲我們既有 react 的項目又有 react-native 的項目,react-native 並不會用 webpack 打包,也就不會用 terser 來壓縮。"}]},{"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":"其實源碼到最終代碼過程中會經歷很多次 ast 的解析,比如 eslint、babel、terser 等,除了 eslint 主要是用來檢查 ast,並不會做過多修改,其餘的工具都可以來完成修改 ast,刪除 console 這件事情。terser 不可以用,那麼我們可以考慮用 babel 來做。"}]},{"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":"而且,我們只是希望在生產環境下刪除 console,在開發環境下 console 還是很有用的,如果能擴展一下 console,讓它功能更強大,比如支持顏色的打印,支持文件和代碼行數的提示就好了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"於是我們就開發了本文介紹的這個插件: babel-plugin-console-transform。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"安裝"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"npm install --save-dev babel-plugin-console-transform"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"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":"先看下效果再講實現。"}]},{"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":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/e6\/e653b6164a780c43b0b5799aec83ff1a.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}},{"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}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/19\/19c126c96e37125ea3a7fe7f1e9b05db.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}},{"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}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/f9\/f9f72740722fb592f13ffacf7e46afe4.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}},{"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}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/a4\/a4a12055974b9f642b97f700202c1786.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}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"生產環境刪除了 console,開發環境擴展了一些方法,並且添加了代碼行數和顏色等。"}]},{"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":"br"}},{"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":"按照需求,這個插件需要在不同的環境做不同的處理,生產環境可以刪除 console,開發環境擴展 console。"}]},{"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":"生產環境刪除 console 並不是全部刪除,還需要支持刪除指定 name 的方法,比如 log、warn 等,因爲有時 console.error 是有用的。而且有的時候根據方法名還不能確定能不能刪除,要根據打印的內容來確定是不是要刪。"}]},{"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":"開發環境擴展 console 要求不改變原生的 api,擴展一些方法,這些方法會被轉換成原生 api,但是會額外添加一些信息,比如添加代碼文件和行數的信息,添加一些顏色的樣式信息等。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/88\/880df011cee56a43ae79e2844fe5e7e6.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":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"於是 console-transform 這個插件就有了這樣的參數。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"{\n env: 'production',\n removeMethods: ['log', '*g*', (args) => args.includes('xxxx')],\n additionalStyleMethods: {\n success: 'padding:10px; color:#fff;background:green;',\n danger: 'padding:20px; background:red;font-size:30px; color:#fff;'\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":"其中 env 是指定環境的,可以通過 process.env.NODE_ENV 來設置。"}]},{"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":"removeMethods 是在生產環境下要刪除的方法,可以傳一個 name,支持 glob,也就是 *g*是刪除所有名字包含 g 的方法;而且可以傳一個函數,函數的參數是 console.xxx 的所有參數,插件會根據這個函數的返回值來決定是不是刪除該 console.xxx。多個條件的時候,只要有一個生效,就會刪。"}]},{"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":"additionalStyleMethods 裏面可以寫一些擴展的方法,比如 succes、danger,分別定義了他們的樣式。其實插件本身提供了 red、green、orange、blue、bgRed、bgOrange、bgGreen、bgBlue 方法,通過這個參數可以自定義,開發環境 console 可以隨意的擴展。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"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":"接下來是重頭戲,實現思路了。"}]},{"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":"首先介紹下用到的技術,代碼是用 typescript 寫的,實現功能是基於 @babel\/core,@babel\/types,測試代碼使用 ts-mocha、chai 寫的,代碼的 lint 用的 eslint、prettier。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"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":"babel 會把代碼轉成 ast,插件裏可以對對 ast 做修改,然後輸出的代碼就是轉換後的。babel 的插件需要是一個返回插件信息的函數。"}]},{"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":"如下, 參數是 babelCore 的 api,裏面有很多工具,我們這裏只用到了 types 來生成一些 ast 的節點。返回值是一個 PluginObj 類型的對象。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"import BabelCore, { PluginObj } from '@babel\/core';\nexport default function({\n types,\n}: typeof BabelCore): PluginObj {\n return {\n name: 'console-transform',\n visitor: {...}\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":"其中 ConsoleTransformState 裏面是我們要指定的類型,這是在後面對 ast 處理時需要拿到參數和文件信息時用的。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"export interface PluginOptions {\n env: string;\n removeMethods?: Array;\n additionalStyleMethods?: { [key: string]: string };\n}\nexport interface ConsoleTransformState {\n opts: PluginOptions;\n file: any;\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"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":"PluginOptions 是 options的類型,env 是必須,其餘兩個可選,removeMethods 是一個值爲 string 或 Function 的數組,additionalStyleMethods 是一個值爲 string 的對象。這都是我們討論需求時確定的。(其中 file 是獲取代碼行列數用的,我沒找到它的類型,就用了 any。)"}]},{"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":"返回的插件信息對象有一個 visitor 屬性,可以聲明對一些節點的處理方式,我們需要處理的是 CallExpression 節點。(關於代碼對應的 ast 是什麼樣的,可以用 astexplorer 這個工具看)。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"{\n CallExpression(path, { opts, file }) {\n validateSchema(schema, opts);\n const { env, removeMethods, additionalStyleMethods } = opts;\n const callee = path.get('callee');\n if (\n callee.node.type === 'MemberExpression' &&\n (callee.node.object as any).name === 'console'\n ) {\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":"這個方法就會在處理到 CallExpression 類型的節點時被調用,參數 path 可以拿到一些節點的信息,通過 path.get('callee')拿到調用信息,然後通過 node.type 過濾出 console.xxx() 而不是 xxx()類型的函數調用,也就是 MemberExpression 類型,再通過 callee.node.object 過濾出 console 的方法。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"production 下刪除 console"}]},{"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":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"const methodName = callee.node.property.name as string;\nif (env === 'production') {\n ...\n return path.remove();\n} else {\n const lineNum = path.node.loc.start.line;\n const columnNum = path.node.loc.start.column;\n ...\n path.node.arguments.unshift(...);\n callee.node.property.name = 'log';\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":"先看主要邏輯,production 環境下,調用 path.remove(),這樣 console 就會被刪除,其他環境對 console 的參數(path.node.arguments.)做一些修改,在前面多加一些參數,然後把方法名(callee.node.property.name)改爲 log。主要邏輯是這樣。"}]},{"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":"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":"production 環境下,當有 removeMethods"},{"type":"text","marks":[{"type":"strong"}],"text":" "},{"type":"text","text":"參數時,要根據其中的 name 和 funciton 來決定是否刪除:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"if (removeMethods) {\n const args = path.node.arguments.map(\n item => (item as any).value,\n );\n if (isMatch(removeMethods, methodName, args)) {\n return path.remove();\n }\n return;\n}\nreturn path.remove();"}]},{"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":"通過把 path.node.arguments 把所有的 args 放到一個數組裏,然後來匹配條件。如下,匹配時根據類型是 string 還是 function 決定如何調用。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"const isMatch = (\n removeMethods: Array,\n methodName: string,\n args: any[],\n): boolean => {\n let isRemove = false;\n for (let i = 0; i < removeMethods.length; i++) {\n if (typeof removeMethods[i] === 'function') {\n isRemove = (removeMethods[i] as Function)(args) ? true : isRemove;\n } else if (mm([methodName], removeMethods[i] as string).length > 0) {\n isRemove = true;\n }\n }\n return isRemove;\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":"如果是 function 就把參數作爲參數傳入,根據返回值確定是否刪除,如果是字符串,會用 mimimatch 做 glob 的解析,支持**、 {a,b}等語法。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"非 production 下擴展 console"}]},{"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":"當在非 production 環境下,插件會提供一些內置方法。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"const styles: { [key: string]: string } = {\n red: 'color:red;',\n blue: 'color:blue;',\n green: 'color:green',\n orange: 'color:orange',\n bgRed: 'padding: 4px; background:red;',\n bgBlue: 'padding: 4px; background:blue;',\n bgGreen: 'padding: 4px; background: green',\n bgOrange: 'padding: 4px; background: orange',\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":"結合用戶通過 addtionalStyleMethods 擴展的方法,來對代碼做轉換:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"const methodName = callee.node.property.name as string;\nconst lineNum = path.node.loc.start.line;\nconst columnNum = path.node.loc.start.column;\nconst allStyleMethods = {\n ...styles,\n ...additionalStyleMethods,\n};\nif (Object.keys(allStyleMethods).includes(methodName)) {\n const ss = path.node.arguments.map(() => '%s').join('');\n path.node.arguments.unshift(\n types.stringLiteral(`%c${ss}%s`),\n types.stringLiteral(allStyleMethods[methodName]),\n types.stringLiteral(\n `${file.opts.filename.slice(\n process.cwd().length,\n )} (${lineNum}:${columnNum}):`,\n ),\n );\n callee.node.property.name = 'log';\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":"通過 methodName"},{"type":"text","marks":[{"type":"strong"}],"text":" "},{"type":"text","text":"判斷出要擴展的方法,然後在參數(path.node.arguments)中填入一些額外的信息 ,這裏就用到了@babel\/core 提供的 types(其實是封裝了@babel\/types 的 api)來生成文本節點了,最後把擴展的方法名都改成 log。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"options 的校驗"}]},{"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":"我們邏輯寫完了,但是 options 還沒有校驗,這裏可以用 schema-utils 這個工具來校驗,通過一個 json 對象來描述解構,然後調用 validate 的 api 來校驗。webpack 那麼複雜的 options 就是通過這個工具校驗的。"}]},{"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":"schema 如下,對 env、removeMethods、additionalStyleMethods"},{"type":"text","marks":[{"type":"strong"}],"text":" "},{"type":"text","text":"都是什麼格式做了聲明。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"export default {\n type: 'object',\n additionalProperties: false,\n properties: {\n env: {\n description:\n 'set the environment to decide how to handle `console.xxx()` code',\n type: 'string',\n },\n removeMethods: {\n description:\n 'set what method to remove in production environment, default to all',\n type: 'array',\n items: {\n description:\n 'method name or function to decide whether remove the code',\n oneOf: [\n {\n type: 'string',\n },\n {\n instanceof: 'Function',\n },\n ],\n },\n },\n additionalStyleMethods: {\n description:\n 'some method to extend the console object which can be invoked by console.xxx() in non-production environment',\n type: 'object',\n additionalProperties: true,\n },\n },\n required: ['env'],\n};"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"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":"代碼寫完了,就到了測試環節,測試的完善度直接決定了這個工具是否可用。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/58\/58fa1b9c1b471b1267de7fe8a4e27bb4.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":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"options 的測試就是傳入各種情況的 options 參數,看報錯信息是否正確。這裏有個知識點,因爲 options 需要傳錯,所以肯定類型不符合,使用 as any as PluginOptions 的雙重斷言可以繞過類型校驗。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"describe('options格式測試', () => {\n const inputFilePath = path.resolve(\n __dirname,\n '.\/fixtures\/production\/drop-all-console\/actual.js',\n );\n it('env缺失會報錯', () => {\n const pluginOptions = {};\n assertFileTransformThrows(\n inputFilePath,\n pluginOptions as PluginOptions,\n new RegExp(\".*configuration misses the property 'env'*\"),\n );\n });\n it('env只能傳字符串', () => {\n const pluginOptions = {\n env: 1,\n };\n assertFileTransformThrows(\n inputFilePath,\n (pluginOptions as any) as PluginOptions,\n new RegExp('.*configuration.env should be a string.*'),\n );\n });\n it('removeMethods的元素只能是string或者function', () => {\n const pluginOptions = {\n env: 'production',\n removeMethods: [1],\n };\n assertFileTransformThrows(\n inputFilePath,\n (pluginOptions as any) as PluginOptions,\n new RegExp(\n '.*configuration.removeMethods[.*] should be one of these:s[ ]{3}string | function.*',\n ),\n );\n });\n it('additionalStyleMethods只能是對象', () => {\n const pluginOptions: any = {\n env: 'production',\n additionalStyleMethods: [],\n };\n assertFileTransformThrows(\n inputFilePath,\n pluginOptions as PluginOptions,\n new RegExp(\n '.*configuration.additionalStyleMethods should be an object.*',\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":"主要的還是 plugin 邏輯的測試。"}]},{"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":"@babel\/core 提供了"},{"type":"text","marks":[{"type":"italic"}],"text":" "},{"type":"text","text":"transformFileSync 的 api,可以對文件做處理,我封裝了一個工具函數,對輸入文件做處理,把結果的內容和另一個輸出文件做對比。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"const assertFileTransformResultEqual = (\n inputFilePathRelativeToFixturesDir: string,\n outputFilePath: string,\n pluginOptions: PluginOptions,\n): void => {\n const actualFilePath = path.resolve(__dirname, '.\/fixtures\/', inputFilePathRelativeToFixturesDir,);\n const expectedFilePath = path.resolve(__dirname,'.\/fixtures\/',outputFilePath);\n const res = transformFileSync(inputFilePath, {\n babelrc: false,\n configFile: false,\n plugins: [[consoleTransformPlugin, pluginOptions]]\n });\n assert.equal(\n res.code,\n fs.readFileSync(expectedFilePath, {\n encoding: 'utf-8',\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":"fixtures 下按照 production 和其他環境的不同場景分別寫了輸入文件 actual 和輸出文件 expected。比如 production 下測試 drop-all-console、drop-console-by-function 等 case,和下面的測試代碼一一對應。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/7d\/7ddef8a49f2b5b6e5b01d21ef134601f.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":"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}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"describe('plugin邏輯測試', () => {\n describe('production環境', () => {\n it('默認會刪除所有的console', () => {\n const pluginOptions: PluginOptions = {\n env: 'production',\n };\n assertFileTransformResultEqual(\n 'production\/drop-all-console\/actual.js',\n 'production\/drop-all-console\/expected.js',\n pluginOptions,\n );\n });\n it('可以通過name刪除指定console,支持glob', () => {...});\n it('可以通過function刪除指定參數的console', () => {...}\n});\n describe('其他環境', () => {\n it('非擴展方法不做處理', () => {...});\n it('默認擴展了red 、green、blue、orange、 bgRed、bgGreen等方法,並且添加了行列數', () => {...});\n it('可以通過additionalStyleMethods擴展方法,並且也會添加行列數', () => {...});\n it('可以覆蓋原生的log等方法', () => {...});\n });\n});"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"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":"babel-plugin-console-transform 這個插件雖然功能只是處理 console,但細節還是蠻多的,比如刪除的時候要根據 name 和 function 確定是否刪除,name 支持 glob,非 production 環境要支持用戶自定義擴展等等。"}]},{"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":"技術方面,用了 schema-utils 做 options 校驗,用 ts-mocha 結合斷言庫 chai 做測試,同時設計了一個比較清晰的目錄結構來組織測試代碼。"}]},{"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":"麻雀雖小,五臟俱全,希望大家能有所收穫。這個插件在我們組已經開始使用,大家也可以使用,有 bug 或者建議可以提 issue 和 pr。"}]},{"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\/c9Al8k9itQp8pHqvB-pAZQ"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"原文:如何用 typescript 寫一個處理 console 的 babel 插件"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"來源:Qunar技術沙龍 - 微信公衆號 [ID:QunarTL]"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"轉載:著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。"}]}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章