Loading...
墨滴

HelloGitHub

2021/05/17  阅读:75  主题:橙心

那些好用的 VS Code 插件,究竟是如何提高编码效率?

上一篇文章中我们已经对 vscode 插件有了一个初步的认识与了解了,接下去我们就要“揭秘”一下市面上那些好用的 vscode 插件究竟是如何帮我们提高工作效率的。

一、从「整体」到「局部」

在开始正题之前,我们先回忆一下自己在 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

2021/05/17  阅读:75  主题:橙心

作者介绍

HelloGitHub

公众号:HelloGitHub可以找到我