那些好用的 VS Code 插件,究竟是如何提高編碼效率的?
- 2021 年 5 月 17 日
- 筆記
在上一篇文章中我們已經對 vscode 插件有了一個初步的認識與了解了,接下去我們就要「揭秘」一下市面上那些好用的 vscode 插件究竟是如何幫我們提高工作效率的。
本文首發於「HelloGitHub」公眾號,搜索「HelloGitHub」點擊關註解鎖更多寶藏!
一、從「整體」到「局部」
在開始正題之前,我們先回憶一下自己在 VS Code 上常用並且獲得編碼幸福度的是不是包含以下幾個點。
1.1、Snippet – 代碼片段
我們經常可以在不同後綴的文件還有文件里不同地方都看到代碼片段。輸入約定的幾個短短字符,就可以擁有一片或大或小的代碼段,解放雙手,節約時間,還能提升每日代碼量。
以下圖片來自插件: vue-vscode-snippets
1.2、代碼提示
解救「懶癌」的另一個常用「解藥」就是代碼提示了。可能平時你並不會注意到它,但是這個功能對於像我一樣單詞記憶水平一般且記不全所有枚舉值的人來說,簡直就是完美!
以下圖片來自插件: vue-helper
二、從「遠觀」到「實踐」
相信看了上面的例子,聰明的你已經深有體感啦。那接下去我們就直奔主題——實現上面所說的代碼片段和代碼提示功能!在這之前,我們先回到 VS Code 官網來看一下 Language Extensions API以及他可以幫我們實現哪些。
首先 Visual Studio Code 通過語言擴展為不同的編程語言提供了智能編輯功能。雖然他不提供內置語言支持,但卻提供了一組支持豐富語言功能的 API。總的來說,VS Code 插件語言類相關的 API 分為兩大類,一類是「聲明語言特性」,一類是「程序語言特性」。前者主要通過在配置文件中定義,而後者通過在代碼中註冊而激活。
2.1、Snippet Completion
我們首先從「聲明語言特性」的代碼片段入手,看看僅僅一份配置文件是如何幫助我們提高工作效率的。
首先,我們在 package.json 裏面增加一個 snippets
的入口,位於 contributes
的下級:
"contributes": {
"commands": [
{
"command": "test.helloGitHub",
"title": "Hello World"
},
{
"command": "test.button",
"title": "按鈕",
"icon": {
"light": "./media/light/preview.svg",
"dark": "./media/dark/preview.svg"
}
}
],
"menus": {
"editor/title": [
{
"command": "test.button",
"group": "navigation",
"when": "resourceLangId == javascript"
}
],
"editor/context": [
{
"command": "test.button",
"group": "navigation",
"when": "resourceLangId == javascript"
}
]
},
// 就是這裡了!!
"snippets": [
{
"language": "javascript",
"path": "./snippets/javascript.json"
}
]
},
也就是這個位置,需要你手動新建一個文件夾和文件:
接下去就是重點、重點、重點。我們如何寫代碼片段的配置文件呢?如果你抱着強烈的好奇心,你可以前往官網查看這份詳細的教程。如果你想先看一眼簡單的配置該如何寫,那就隨着本文一起來看吧~
我們還是先「眼見為實」來看看下面的這份配置,會有什麼奇妙的效果,先上配置代碼:
{
"forLoop": {
"prefix": "for",
"body": [
"for(let i = 0; i < ${1: array.length}); i++) {",
"\t$BLOCK_COMMENT_START HelloGitHub: 這裡可以寫你的代碼 $BLOCK_COMMENT_END",
"}"
],
"description": "for 循環"
}
}
再來看看插件運行後的提示效果(一定要看仔細哪個是來自我們插件的哦):
最後我們自信的按下「Enter」回車鍵,就會看到一段代碼已經在我們的 js
文件里了
for(let i = 0; i < array.length); i++) {
/* HelloGitHub: 這裡可以寫你的代碼 */
}
那我們就來回顧一下上面那份配置文件,究竟是如何生成這一份代碼的。
字段 | 含義 |
---|---|
forLoop | 是代碼段名稱。如果未提供 description,則通過 IntelliSense 顯示 |
prefix | 定義一個或多個在 IntelliSense 中顯示摘要的觸發詞。 |
body | 是一或多個內容行,插入時將作為多行加入。換行符和嵌入的選項卡將根據插入代碼段的上下文進行格式化 |
description | IntelliSense 顯示的代碼段的描述(非必填) |
首先這份配置會有一個名字即 forLoop
,是可以用戶隨意自定義的,我們可以看到它支持大小寫,加空格還有加橫杠,當然你或許要問它支不支持中文,那我可以告訴你:支持。但是並不建議這麼寫,因為我們的眼界要放大嘛,走向國際(international)~
其次如果你想要匹配多個 prefix
,你可以修改你的代碼如下:
{
"forLoop": {
"prefix": ["for", "for-const"],
"body": [
"for(let i = 0; i < ${1:array.length}); i++) {",
"\t$BLOCK_COMMENT_START HelloGitHub: 這裡可以寫你的代碼 $BLOCK_COMMENT_END", // \t 表示縮進,$BLOCK_COMMENT_START 和 $BLOCK_COMMENT_END 表示注釋的開始和結束。 // 和 /**/ 這兩種都支持
"}"
],
"description": "for 循環"
}
}
效果如下:
而且子字符串匹配是在前綴上執行的,因此,在這種情況下, fc
可以匹配 for-const
:
呈現的代碼片段:
1、Tabstops
控制編輯器光標在代碼內移動。你可以使用$1
,$2
指定游標的位置,數字表示 Tab 鍵訪問的順序,出現相同的會被同步更新,$0
表示光標最後一個位置,當光標位於指定位置的情況下就會退出這個模式。
可能光看文字你會有點迷糊,那我們直接修改上面的 for
循環:
{
"For Loop": {
"prefix": ["for", "for-const"],
"body": ["for (const ${2:element} of ${1:array}) {", "\t$0", "}"],
"description": "A for loop."
}
}
效果(用 tab 切換),順序是 $1
> $2
> $0
:
2、佔位符
其實從前面的例子你應該就知道了佔位符這個東西就是一個帶有默認值的語法,例如${1:foo}
。佔位符文本將被插入和選擇,以便用戶可以輕鬆更改。並且佔位符還可以進行嵌套,例如${1:another ${2:placeholder}}
3、選擇
當然啦對於喜歡偷懶的「我們」來說,能省一點時間是一點時間,因此佔位符也可以讓我們只動動上下鍵就可以完成輸入。語法是用逗號分隔的值枚舉,觸發插入代碼段並選擇佔位符後,選項將提示用戶選擇其中一個值。
修改我們的代碼如下:
{
"forLoop": {
"prefix": ["for", "for-const"],
"body": [
"for(let i = 0; i < ${1:array.length}); i++) {",
"\t$BLOCK_COMMENT_START HelloGitHub: 這裡可以寫你的代碼 $BLOCK_COMMENT_END",
"\t\t${2|one,two,three|}",
"}"
],
"description": "for 循環"
},
}
效果:
4、變量
不知道你有沒有注意上面代碼中的一個小注釋:
{
...
"\t$BLOCK_COMMENT_START HelloGitHub: 這裡可以寫你的代碼 $BLOCK_COMMENT_END", // \t 表示縮進,$BLOCK_COMMENT_START 和 $BLOCK_COMMENT_END 表示注釋的開始和結束。 // 和 /**/ 這兩種都支持
...
}
裏面就用到了一個注釋的變量 $BLOCK_COMMENT_START
和 $BLOCK_COMMENT_END
。這個語法允許我們使用$name
或${name:default}
這兩種方式來設置插入的變量值。未設置變量時,將插入其默認值或空字符串。當變量未知(即未定義其名稱)時,將插入該變量的名稱,並將其轉換為佔位符。從 VS Code 官網上可以看到所有支持的變量:
比如我們修改我們的例子如下:
{
"forLoop": {
"prefix": ["for", "for-const"],
"body": [
"for(let i = 0; i < ${1:array.length}); i++) {",
"\t$BLOCK_COMMENT_START HelloGitHub: 這裡可以寫你的代碼 $BLOCK_COMMENT_END",
"\t\tconsole.log('choice', ${2|one,two,three|})",
"\t\tconsole.log('year', ${CURRENT_YEAR})",
"\t\treturn ${name:value}",
"}"
],
"description": "for 循環"
}
效果:
到這個例子為止你會發現我們的代碼片段變得越來越長,越來越豐富,也就是我們可以偷的懶就「越來越多」,不經意間就可以提高開發效率有沒有?
可能我的例子太簡單你沒有體感,那我們來看一個這個,應該有非常多的人眼熟:
對應的代碼配置其實也就是我們上面說的那幾個語法:
{
"hellogithub": {
"prefix": "swiper",
"body": [
"<swiper $0 indicator-dots=\"{{${1:indicatorDots}}}\" autoplay=\"{{${2:autoplay}}}\" interval=\"{{${3:interval}}}\" duration=\"{{${4:duration}}}\">",
"\t<block wx:for=\"{{${5:imgUrls}}}\">",
"\t\t<swiper-item>",
"\t\t\t<image src=\"{{${6:item}}}\" class=\"slide-image\" />",
"\t\t</swiper-item>",
"\t</block>",
"</swiper>$7"
],
"description": "滑塊視圖容器"
}
}
當然啦如果你有志於寫一個非常好用的代碼片段,上面這些可能還不能滿足你的話,可以學習一下 TextMate 更多高級的語法(上文中其實算是 TextMate 的基礎語法,言外之意就是比較常用而且看起來就很簡單易懂)。簡單的介紹一下 TextMate,它是 Mac下的著名的文本編輯器軟件,它可以根據一定的語言規則可以匹配文檔的結構,也可以按照一定的語法規則快速生成代碼片段。
2.2、Completion Provider
1、初窺
上面介紹了通過配置就可以完成的「聲明類語言特性」,讓我們再來看一個「程序類語言特性」—— registerCompletionItemProvider
。
我們首先看個圖,是不是也覺得是個「偷懶」神器呀!但是你有沒有疑惑過,為什麼這個編輯器知道我們即將要寫的是什麼?為什麼它還可以給我們推薦寫什麼?如果你覺得這是計算機時代智慧的結晶的話,那我也不能說你錯。那麼今天,我們就親自來「揭秘」這個功能,可以用registerCompletionItemProvider
這個來實現。
接下去我們就進入代碼實現了,還記得上一篇文章的 extension.js
嗎?我們在這裡加上這麼一段代碼:
const completion = vscode.languages.registerCompletionItemProvider(
'javascript',
{
provideCompletionItems(document, position) {
const linePrefix = document.lineAt(position).text.substr(0, position.character);
if (!linePrefix.endsWith('hello.')) {
return undefined;
}
return [
new vscode.CompletionItem('HelloGitHub', vscode.CompletionItemKind.Property),
new vscode.CompletionItem('HelloWorld', vscode.CompletionItemKind.Property),
new vscode.CompletionItem('HelloPeople', vscode.CompletionItemKind.Property),
];
}
},
'.' // triggered whenever a '.' is being typed
);
context.subscriptions.push(completion);
然後先來看一下效果:
這裡可能會有小夥伴掉進「坑裡」——如果你在實現的過程中發現效果出不來可以按下面的思路先判斷和解決試試:
-
1、看一下當前文件的後綴是不是正確的。比如上面代碼里規定了
javascript
,那就要在.js
後綴的文件裏面才有效 -
2、註冊命令當然也和插件的生命周期息息相關,如果你發現上一步是正確的,那你就要去
package.json
文件裏面看看activationEvents
裏面的命令是否觸發了。如果你忘記如何觸發插件激活的生命周期,那你就改成這樣。
...
"activationEvents": [
"*"
],
...
- 3、如果上面兩個還沒有解決你的問題的話,那肯定是你上面代碼 ctrl+c ctrl+v 的不對!開個玩笑,如果你還是不能實現的話……那你就留言評論點個贊來個三聯么么噠~
回歸一下正題,我們來分析一下上面的代碼是如何實現的:
const completion = vscode.languages.registerCompletionItemProvider(
// 這裡是註冊這個 Provider 有效的相關文件,支持字符串類型或 DocumentFilter 對象。
// 如果你要對多個後綴的文件做操作的話可以用數組的形式,例如 ['javascript', 'plaintext']
// DocumentFilter 對象包含三個字段(均非必須),例如:{ language: 'json', scheme: 'untitled', pattern: '**/package.json' }
'javascript',
...
}
...
{
// 這是代表了一個 provider
provideCompletionItems(document, position) {
// 拿到當前 `position` 的 text 並且判斷一下是否以 `hello.` 開頭
const linePrefix = document.lineAt(position).text.substr(0, position.character);
// 沒有匹配到則不予提示
if (!linePrefix.endsWith('hello.')) {
return undefined;
}
// 如果匹配成功就返回 CompletionItem 有:HelloGitHub、HelloWorld、HelloPeople
return [
new vscode.('HelloGitHub', vscode.CompletionItemKind.Property),
new vscode.CompletionItem('HelloWorld', vscode.CompletionItemKind.Property),
new vscode.CompletionItem('HelloPeople', vscode.CompletionItemKind.Property),
];
}
},
...
可能你會疑惑, vscode.CompletionItemKind.Property
是什麼東西呢?說簡單一點其實就是個圖標的配置。我們可以換幾個屬性來看看差別:
...
return [
new vscode.CompletionItem('HelloGitHub', vscode.CompletionItemKind.Method),
new vscode.CompletionItem('HelloWorld', vscode.CompletionItemKind.Enum),
new vscode.CompletionItem('HelloPeople', vscode.CompletionItemKind.Property),
];
...
從 index.d.ts
可以看到它支持以下這麼多類型的圖標,可以根據不同的需求來選擇你想要的圖標,當然啦這裡就不重點展開啦,有興趣的可以自己把這些圖標都整理一下~
/**
* Completion item kinds.
*/
export enum CompletionItemKind {
Text = 0,
Method = 1,
Function = 2,
Constructor = 3,
Field = 4,
Variable = 5,
Class = 6,
Interface = 7,
Module = 8,
Property = 9,
Unit = 10,
Value = 11,
Enum = 12,
Keyword = 13,
Snippet = 14,
Color = 15,
Reference = 17,
File = 16,
Folder = 18,
EnumMember = 19,
Constant = 20,
Struct = 21,
Event = 22,
Operator = 23,
TypeParameter = 24,
User = 25,
Issue = 26,
}
最後就解釋一下這個觸發條件:
...
'.' // 當鍵盤打 . 的時候觸發,支持多個觸發
...
我們可能會遇到不同場景需要不同的觸發條件,這時候就儘管往後加就好了,例如我們新加幾個特殊符號的觸發條件(這裡先去掉匹配字符串的邏輯,以便於更好的觸發):
const completion = vscode.languages.registerCompletionItemProvider(
['javascript', 'xml'],
{
provideCompletionItems(document, position) {
// const linePrefix = document.lineAt(position).text.substr(0, position.character);
// if (!linePrefix.endsWith('hello')) {
// return undefined;
// }
return [
new vscode.CompletionItem('HelloGitHub', vscode.CompletionItemKind.Method),
new vscode.CompletionItem('HelloWorld', vscode.CompletionItemKind.Enum),
new vscode.CompletionItem('HelloPeople', vscode.CompletionItemKind.Property),
];
}
},
'.',
',',
' '
);
2、進階
但是正常情況下,我們往往需要去解析用戶輸入的不同內容,來給與不同對應的 completion item。所以接下去我們就以 xml
文件為例,來寫一個「功能強大」的 Completion Proviwder。
先來分析一下 xml
這種文件常見的 Completion Provider 大致有這麼三種:
-
標籤名
-
屬性名
-
屬性值
當然啦,如果像是 vue
裏面 template
模板的寫法,其實還有事件名這類等。那我們就以 @ 符號作為事件名提示的觸發條件,以 < 作為標籤名提示的觸發條件,以空格、回車作為屬性名的觸發條件,以單雙引號作為屬性值的觸發條件,先寫一個簡單的實現:
// 引入兩個 mock 文件
const testEventName = require("./mock/testEventName");
const testTagName = require("./mock/testTagName");
...
const completion = vscode.languages.registerCompletionItemProvider(
'xml',
{
provideCompletionItems(
document, // 命令被調用的文檔
position, // 命令被調用的位置
token, // 取消令牌
context // 自動補全是怎麼觸發的
) {
// 如果校驗命中了取消令牌,就不提示
if (token.isCancellationRequested) {
return Promise.resolve([])
}
let char = context.triggerCharacter
switch (char) {
case '<': // 標籤名提示
// todo
case '@': // 綁定事件
// todo
default: // 屬性名、屬性值等
// todo
}
}
},
'@',
'\n',
' ',
'"',
"'",
'<'
)
mock 文件可以隨便定一個結構,下面是本文例子中用到的 mock 數據結構(兩個文件):
// ./mock/testEventName
module.exports = [
{
name: 'onTap',
id: 'ontap',
desc: '這是一個點擊事件的描述'
},
{
name: 'for',
id: 'for',
desc: '這是一個循環事件的描述'
}
]
// ./mock/testTagName
module.exports = [
{
name: 'HelloGitHub',
id: 'hg',
description: '這是我們的名字'
},
{
name: 'Welcome',
id: 'wlc',
description: '歡迎關注和喜歡我們'
}
]
先來實現一下標籤名的 Completion Provider:
const completionArr = []
for (let i = 0; i < testTagName.length; i++) {
const commandCompletion = new vscode.CompletionItem(testTagName[i].name);
commandCompletion.kind = vscode.CompletionItemKind.Property;
commandCompletion.documentation = new vscode.MarkdownString(testTagName[i].description);
let snippet = `${testTagName[i].name}\n` +
' name="${1:HelloGitHub}"\n' +
' desc="${2:We are serious about open source}"\n' +
'>\n' +
`</${testTagName[i].name}>`;
commandCompletion.insertText = new vscode.SnippetString(snippet);
completionArr.push(commandCompletion)
}
return completionArr;
我們可以看到和上面講過的內容差不多,也是需要 new
一個 CompletionItem
對象,但是這裡把這個對象更加的「豐富化」了,通過增加屬性的方式給這個 CompletionItem
增加了圖標——kind
、說明——documentation
、還有片段——insertText
。
讓我們來看一下效果,如果沒有自動出現說明,就點一下 Completion 最右側的小箭頭:
同樣的我們也來寫一下事件的 Completion Provider,簡直就是 ctrl+c 和 ctrl+v:
if (testEventName && testEventName.length > 0) {
const arr = []
for(let i = 0; i < testEventName.length; i++) {
const item = testEventName[i]
const commandCompletion = new vscode.CompletionItem(item.name);
commandCompletion.kind = vscode.CompletionItemKind.Property;
commandCompletion.documentation = new vscode.MarkdownString(item.desc || '暫無介紹');
let snippet = `${item.name}{}`;
commandCompletion.insertText = new vscode.SnippetString(snippet);
arr.push(commandCompletion)
}
return arr
}
return []
效果:
接下去我們就要攻克最後的一個點:屬性值和屬性名。這就涉及到分析當前文本的結構,我們默認單雙引號所在的位置標示屬性值,挨着 < 符號的是標籤名,剩下的就都是作為屬性值。
所以第一步,我們寫一個方法,用來解析和獲取我們上面想要知道的文檔結構,這一部分的代碼我們寫到一個新的文件引用過去(getTagAtPosition.js
):
function getTagAtPosition(doc, pos) {
let offset = doc.offsetAt(pos);
let text = doc.getText();
// 因為引號里可能會有任何字符,所以做一層替換處理
let attrFlagText = text.replace(/("[^"]*"|'[^']*')/g, replacer('%'));
// 標籤起始位置 [start,length]
const range = getBracketRange(attrFlagText, offset);
if (!range) {
return null
}
const [start, end] = range;
offset = offset - start;
text = text.substr(start, end);
attrFlagText = attrFlagText.substr(start, end);
const tagNameMatcher = attrFlagText.match(/^<([\w-:.]+)/);
if (!tagNameMatcher) {
return null;
}
const name = tagNameMatcher[1]; // 標籤名稱
const isOnAttrValue = attrFlagText[offset] === '%';
const attrName = isOnAttrValue ? getAttrName(attrFlagText.substring(0, offset)) : '' // 當前輸入對應的屬性
const isOnTagName = offset <= name.length + 1;
const isOnAttrName = !isOnTagName && !isOnAttrValue
return {
name, // 標籤名
attrName, // 屬性名
isOnTagName, // 是否處於 tag 上
isOnAttrName, // 是否處於屬性名上
isOnAttrValue, // 是否處於屬性值上
}
}
// 字符替換的方法
const replacer = (char) => (raw) => char.repeat(raw.length);
// 獲取 <> 標籤的位置
function getBracketRange(text, pos) {
const textBeforePos = text.substr(0, pos)
const startBracket = textBeforePos.lastIndexOf('<')
if (startBracket < 0 || textBeforePos[startBracket + 1] === '!' || textBeforePos.lastIndexOf('>') > startBracket) {
// 前沒有開始符<,
// 或者正在注釋中: <!-- | -->
// 或者不在標籤中: <view > | </view>
return null
}
// 從光標位置後面找 > 標籤
let endBracket = text.indexOf('>', pos + 1)
if (endBracket < 0) {
// 未找到閉合 > 文件結束位置為結束
// 如 <image ... | EOF
endBracket = text.length
}
// 可能尚未輸入閉合標籤,取下一個標籤的頭<
// 此時找到的閉合標籤是下一個標籤
// <view xxx | ... <view ></view>
const nextStart = text.indexOf('<', pos + 1)
if (nextStart > 0 && nextStart < endBracket) {
endBracket = nextStart
}
return [startBracket, endBracket - startBracket]
}
對應 extension.js
裏面加上我們新寫的邏輯:
...
default: // 屬性、標籤等
// step1. 找最近的標籤名
let tag = getTagAtPosition(document, position);
if (!tag) {
return null
}
// 屬性值提示
if (tag.isOnAttrValue) {
return getAttrValueCompletionArr(tag.attrName || '', targetObj.children)
} else {
// 屬性提示
return getAttrCompletionArr(targetObj.children)
}
...
接下來我們加一個新的 mock 數據,並且結構是一個樹狀結構,每個標籤下面都有它可能的屬性名列表(children
),同時每一個屬性名都有對應的屬性值列表(children
):
module.exports = [
{
name: 'HelloGitHub',
id: 'hg',
description: '這是我們的名字',
children: [
{
name: 'hgAttrName1',
children: [
{
name: 'hgAttrVal1'
},
{
name: 'hgAttrVal2'
}
]
},
{
name: 'hgAttrName2'
}
]
},
{
name: 'Welcome',
id: 'wlc',
description: '歡迎關注和喜歡我們'
}
]
看一下上面 getAttrCompletionArr
這個方法做的事情,其實就是從數據里取值出來展示這麼簡單:
function getAttrCompletionArr (completionArr) {
const arr = []
if (completionArr.length > 0) {
for(let j = 0; j < completionArr.length; j++) {
if (completionArr[j] && completionArr[j].name) {
const commandCompletion = new vscode.CompletionItem(completionArr[j].name);
commandCompletion.kind = vscode.CompletionItemKind.Property;
arr.push(commandCompletion)
}
}
}
return arr
}
module.exports = getAttrCompletionArr;
那屬性值的列表的話,我們就要知道它是在哪個標籤名下的屬性名下面了:
function getAttrValueCompletionArr (attrName, completionArr) {
const enumValue = completionArr.find(item => item.name === attrName) || {};
if (enumValue.children && enumValue.children.length > 0) {
const arr = []
for(let i = 0; i < enumValue.children.length; i++) {
const commandCompletion = new vscode.CompletionItem(enumValue.children[i].name);
commandCompletion.kind = vscode.CompletionItemKind.Property;
arr.push(commandCompletion)
}
return arr
}
return []
}
最後的效果:
可能有的朋友對於上面一串解析文檔的方法有很多疑惑,代碼里雖然有注釋,但是可能還是沒有體感,這時候就建議最好動手實踐一下,因為都是 VS Code Extension 提供的方法,所以這裡不會過多展開,畢竟也不是這篇文章的重點內容嘛~
三、「總結」和「預告」
那今天給大家介紹了兩種「偷懶」並且可以幫助我們提高打代碼效率的兩種方法:
-
代碼片段(Snippet)
-
自動補充(Completion Provider)
也是眾多 VS Code 插件中非常常見的功能之一,其實走近了看也不是很難吧~
今天的內容可能略多一點,如果你看完了第一篇,第二篇是在第一篇基礎上改的,相信你一定可以跟得上。那下篇文章,我們就要來看看 VS Code 插件中另一個非常強大的功能——WebView。也就是支持在插件中打開網頁、和網頁通信、還可以寫酷炫的 CSS 樣式等等。雖然它的功能很強大,但是像一把雙刃劍,他對於資源的佔用也是很大的,想知道可以怎麼用嗎?請期待下一期。
關注 HelloGitHub 公眾號 第一時間收到更新。
還有更多開源項目的介紹和寶藏項目等待你的發現。