手机游戏巴士

Gitter:高颜值GitHub小程序客户端诞生记

发表于:2024-11-15 作者:游戏编辑
编辑最后更新 2024年11月15日,选自GitHub作者:Huangjianke0.前言嗯,可能一进来大部分人都会觉得,为什么还会有人重复造轮子,GitHub第三方客户端都已经烂大街啦。确实,一开始我...


选自GitHub


作者: Huangjianke

0. 前言

嗯,可能一进来大部分人都会觉得,为什么还会有人重复造轮子,GitHub 第三方客户端都已经烂大街啦。确实,一开始我自己也是这么觉得的,也问过自己是否真的有意义再去做这样一个项目。思考再三,以下原因也决定了我愿意去做一个让自己满意的 GitHub 第三方客户端。



  • 对于时常关注 GitHub Trending 列表的笔者来说,迫切需要一个更简单的方式随时随地去跟随 GitHub 最新的技术潮流;



  • 已有的一些 GitHub 小程序客户端颜值与功能并不能满足笔者的要求;



  • 听说 iOS 开发没人要了,掌握一门新的开发技能,又何尝不可?



  • 其实也没那么多原因,既然想做,那就去做,开心最重要。



1. Gitter



  • GitHub:https://github.com/huangjianke/Gitter,可能是目前颜值最高的 GitHub 小程序客户端,欢迎 star



  • 数据来源:GitHub API v3



目前实现的功能有:



  • 实时查看 Trending



  • 显示用户列表



  • 仓库和用户的搜索



  • 仓库:详情展示、README.md 展示、Star/Unstar、Fork、Contributors 展示、查看仓库文件内容



  • 开发者:Follow/Unfollow、显示用户的 followers/following



  • Issue:查看 issue 列表、新增 issue、新增 issue 评论



  • 分享仓库、开发者



  • ...



Gitter 的初衷并不是想把网页端所有功能照搬到小程序上,因为那样的体验并不会很友好,比如说,笔者自己也不想在手机上阅读代码,那将会是一件很痛苦的事。

在保证用户体验的前提下,让用户用更简单的方式得到自己想要的,这是一件有趣的事。

2. 探索篇

技术选型

第一次觉得,在茫茫前端的世界里,自己是那么渺小。

当决定去做这个项目的时候,就开始了马不停蹄的技术选型,但摆在自己面前的选择是那么的多,也不得不感慨,前端的世界,真的很精彩。



  • 原生开发:基本上一开始就放弃了,开发体验很不友好;



  • WePY:之前用这个框架已经开发过一个小程序,诗词墨客,不得不说,坑是真多,用过的都知道;



  • mpvue:用 Vue 的方式去开发小程序,个人觉得文档并不是很齐全,加上近期维护比较少,可能是趋于稳定了?



  • Taro:用 React 的方式去开发小程序,Taro 团队的小伙伴维护真的很勤快,也很耐心的解答大家疑问,文档也比较齐全,开发体验也很棒,还可以一键生成多端运行的代码 (暂没尝试)



货比三家,经过一段时间的尝试及踩坑,综合自己目前的能力,最终确定了 Gitter 的技术选型:


Taro + Taro UI + Redux + 云开发 Node.js

页面设计

其实,作为一名 Coder,曾经一直想找个 UI 设计师妹子做老婆的 (肯定有和我一样想法的 Coder),多搭配啊。现在想想,code 不是生活的全部,现在的我一样很幸福。

话回正题,没有设计师老婆页面设计怎么办?毕竟笔者想要的是一款高颜值的 GitHub 小程序。

嗯,不慌,默默的拿出了笔者沉寂已久的 Photoshop 和 Sketch。不敢说自己的设计能力如何,Gitter 的设计至少是能让笔者自己心情愉悦的,倘若哪位设计爱好者想对 Gitter 的设计进行改良,欢迎欢迎,十二分的欢迎!

3. 开发篇

Talk is cheap. Show me the code.

作为一篇技术性文章,怎可能少得了代码。

在这里主要写写几个踩坑点,作为一个前端小白,相信各位读者均是笔者的前辈,还望多多指教!

Trending

进入开发阶段没多久,就遇到了第一个坑。

GitHub 居然没有提供 Trending 列表的 API!!!

也没有过多的去想 GitHub 为什么不提供这个 API,只想着怎么去尽快填好这个坑。一开始尝试使用 Scrapy 写一个爬虫对网页端的 Trending 列表信息进行定时爬取及存储供小程序端使用,但最终还是放弃了这个做法,因为笔者并没有服务器与已经备案好的域名,小程序的云开发也只支持 Node.js 的部署。

开源的力量还是强大,最终找到了 github-trending-api,稍作修改,成功部署到小程序云开发后台,在此,感谢原作者的努力。



  • 爬取Trending Repositories


  1. async

    function

    fetchRepositories

    ({

  2.   language

    =

    ""

    ,

  3.   since

    =

    "daily"

    ,

  4. }

    =

    {})

    {

  5.  

    const

    url

    =

    `

    $

    {

    GITHUB_URL

    }/

    trending

    /

    $

    {

    language

    }?

    since

    =

    $

    {

    since

    }`;

  6.  

    const

    data

    =

    await fetch

    (

    url

    );

  7.  

    const

    $

    =

    cheerio

    .

    load

    (

    await data

    .

    text

    ());

  8.  

    return

    (

  9.     $

    (

    ".repo-list li"

    )

  10.      

    .

    get

    ()

  11.      

    // eslint-disable-next-line complexity

  12.      

    .

    map

    (

    repo

    =>

    {

  13.        

    const

    $repo

    =

    $

    (

    repo

    );

  14.        

    const

    title

    =

    $repo

  15.          

    .

    find

    (

    "h3"

    )

  16.          

    .

    text

    ()

  17.          

    .

    trim

    ();

  18.        

    const

    relativeUrl

    =

    $repo

  19.          

    .

    find

    (

    "h3"

    )

  20.          

    .

    find

    (

    "a"

    )

  21.          

    .

    attr

    (

    "href"

    );

  22.        

    const

    currentPeriodStarsString

    =

  23.           $repo

  24.            

    .

    find

    (

    ".float-sm-right"

    )

  25.            

    .

    text

    ()

  26.            

    .

    trim

    ()

    ||

    /* istanbul ignore next */

    ""

    ;

  27.        

    const

    builtBy

    =

    $repo

  28.          

    .

    find

    (

    "span:contains("Built by")"

    )

  29.          

    .

    parent

    ()

  30.          

    .

    find

    (

    "[data-hovercard-type="user"]"

    )

  31.          

    .

    map

    ((

    i

    ,

    user

    )

    =>

    {

  32.            

    const

    altString

    =

    $

    (

    user

    )

  33.              

    .

    children

    (

    "img"

    )

  34.              

    .

    attr

    (

    "alt"

    );

  35.            

    const

    avatarUrl

    =

    $

    (

    user

    )

  36.              

    .

    children

    (

    "img"

    )

  37.              

    .

    attr

    (

    "src"

    );

  38.            

    return

    {

  39.               username

    :

    altString

  40.                

    ?

    altString

    .

    slice

    (

    1

    )

  41.                

    :

    /* istanbul ignore next */

    null

    ,

  42.               href

    :

    `

    $

    {

    GITHUB_URL

    }

    $

    {

    user

    .

    attribs

    .

    href

    }`,

  43.               avatar

    :

    removeDefaultAvatarSize

    (

    avatarUrl

    ),

  44. };

  45. })

  46.          

    .

    get

    ();

  47.        

    const

    colorNode

    =

    $repo

    .

    find

    (

    ".repo-language-color"

    );

  48.        

    const

    langColor

    =

    colorNode

    .

    length

  49.          

    ?

    colorNode

    .

    css

    (

    "background-color"

    )

  50.          

    :

    null

    ;

  51.        

    const

    langNode

    =

    $repo

    .

    find

    (

    "[itemprop=programmingLanguage]"

    );

  52.        

    const

    lang

    =

    langNode

    .

    length

  53.          

    ?

    langNode

    .

    text

    ().

    trim

    ()

  54.          

    :

    /* istanbul ignore next */

    null

    ;

  55.        

    return

    omitNil

    ({

  56.           author

    :

    title

    .

    split

    (

    " / "

    )[

    0

    ],

  57.           name

    :

    title

    .

    split

    (

    " / "

    )[

    1

    ],

  58.           url

    :

    `

    $

    {

    GITHUB_URL

    }

    $

    {

    relativeUrl

    }`,

  59.           description

    :

  60.             $repo

  61.              

    .

    find

    (

    ".py-1 p"

    )

  62.              

    .

    text

    ()

  63.              

    .

    trim

    ()

    ||

    /* istanbul ignore next */

    ""

    ,

  64.           language

    :

    lang

    ,

  65.           languageColor

    :

    langColor

    ,

  66.           stars

    :

    parseInt

    (

  67.             $repo

  68.              

    .

    find

    (`[

    href

    =

    "${relativeUrl}/stargazers"

    ]`)

  69.              

    .

    text

    ()

  70.              

    .

    replace

    (

    ","

    ,

    ""

    )

    ||

    /* istanbul ignore next */

    0

    ,

  71.            

    10

  72.          

    ),

  73.           forks

    :

    parseInt

    (

  74.             $repo

  75.              

    .

    find

    (`[

    href

    =

    "${relativeUrl}/network"

    ]`)

  76.              

    .

    text

    ()

  77.              

    .

    replace

    (

    ","

    ,

    ""

    )

    ||

    /* istanbul ignore next */

    0

    ,

  78.            

    10

  79.          

    ),

  80.           currentPeriodStars

    :

    parseInt

    (

  81.             currentPeriodStarsString

    .

    split

    (

    " "

    )[

    0

    ].

    replace

    (

    ","

    ,

    ""

    )

    ||

  82.              

    /* istanbul ignore next */

    0

    ,

  83.            

    10

  84.          

    ),

  85.           builtBy

    ,

  86. });

  87. })

  88. );

  89. }



  • 爬取Trending Developers


  1. async

    function

    fetchDevelopers

    ({

    language

    =

    ""

    ,

    since

    =

    "daily"

    }

    =

    {})

    {

  2.  

    const

    data

    =

    await fetch

    (

  3.    

    `

    $

    {

    GITHUB_URL

    }/

    trending

    /

    developers

    /

    $

    {

    language

    }?

    since

    =

    $

    {

    since

    }`

  4.  

    );

  5.  

    const

    $

    =

    cheerio

    .

    load

    (

    await data

    .

    text

    ());

  6.  

    return

    $

    (

    ".explore-content li"

    )

  7.    

    .

    get

    ()

  8.    

    .

    map

    (

    dev

    =>

    {

  9.      

    const

    $dev

    =

    $

    (

    dev

    );

  10.      

    const

    relativeUrl

    =

    $dev

    .

    find

    (

    ".f3 a"

    ).

    attr

    (

    "href"

    );

  11.      

    const

    name

    =

    getMatchString

    (

  12.         $dev

  13.          

    .

    find

    (

    ".f3 a span"

    )

  14.          

    .

    text

    ()

  15.          

    .

    trim

    (),

  16.        

    /^

    (

    (.+)

    )$

    /

    i

  17.      

    );

  18.       $dev

    .

    find

    (

    ".f3 a span"

    ).

    remove

    ();

  19.      

    const

    username

    =

    $dev

  20.        

    .

    find

    (

    ".f3 a"

    )

  21.        

    .

    text

    ()

  22.        

    .

    trim

    ();

  23.      

    const

    $repo

    =

    $dev

    .

    find

    (

    ".repo-snipit"

    );

  24.      

    return

    omitNil

    ({

  25.         username

    ,

  26.         name

    ,

  27.         url

    :

    `

    $

    {

    GITHUB_URL

    }

    $

    {

    relativeUrl

    }`,

  28.         avatar

    :

    removeDefaultAvatarSize

    (

    $dev

    .

    find

    (

    "img"

    ).

    attr

    (

    "src"

    )),

  29.         repo

    :

    {

  30.           name

    :

    $repo

  31.            

    .

    find

    (

    ".repo-snipit-name span.repo"

    )

  32.            

    .

    text

    ()

  33.            

    .

    trim

    (),

  34.           description

    :

  35.             $repo

  36.              

    .

    find

    (

    ".repo-snipit-description"

    )

  37.              

    .

    text

    ()

  38.              

    .

    trim

    ()

    ||

    /* istanbul ignore next */

    ""

    ,

  39.           url

    :

    `

    $

    {

    GITHUB_URL

    }

    $

    {

    $repo

    .

    attr

    (

    "href"

    )}`,

  40. },

  41. });

  42. });

  43. }



  • Trending列表云函数


  1. // 云函数入口函数

  2. exports

    .

    main

    =

    async

    (

    event

    ,

    context

    )

    =>

    {

  3.  

    const

    {

    type

    ,

    language

    ,

    since

    }

    =

    event

  4.  let res

    =

    null

    ;

  5.  let date

    =

    new

    Date

    ()

  6.  

    if

    (

    type

    ===

    "repositories"

    )

    {

  7.    

    const

    cacheKey

    =

    `

    repositories

    ::

    $

    {

    language

    ||

    "nolang"

    }::

    $

    {

    since

    ||

  8.    

    "daily"

    }`;

  9.    

    const

    cacheData

    =

    await db

    .

    collection

    (

    "repositories"

    ).

    where

    ({

  10.      cacheKey

    :

    cacheKey

  11.    

    }).

    orderBy

    (

    "cacheDate"

    ,

    "desc"

    ).

    get

    ()

  12.    

    if

    (

    cacheData

    .

    data

    .

    length

    !==

    0

    &&

  13.      

    ((

    date

    .

    getTime

    ()

    -

    cacheData

    .

    data

    [

    0

    ].

    cacheDate

    )

     

    <

    1800

    *

    1000

    ))

    {

  14.      res

    =

    JSON

    .

    parse

    (

    cacheData

    .

    data

    [

    0

    ].

    content

    )

  15.    

    }

    else

    {

  16.      res

    =

    await fetchRepositories

    ({

    language

    ,

    since

    });

  17.      await db

    .

    collection

    (

    "repositories"

    ).

    add

    ({

  18.        data

    :

    {

  19.          cacheDate

    :

    date

    .

    getTime

    (),

  20.          cacheKey

    :

    cacheKey

    ,

  21.          content

    :

    JSON

    .

    stringify

    (

    res

    )

  22.        

    }

  23.      

    })

  24.    

    }

  25.  

    }

    else

    if

    (

    type

    ===

    "developers"

    )

    {

  26.    

    const

    cacheKey

    =

    `

    developers

    ::

    $

    {

    language

    ||

    "nolang"

    }::

    $

    {

    since

    ||

    "daily"

    }`;

  27.    

    const

    cacheData

    =

    await db

    .

    collection

    (

    "developers"

    ).

    where

    ({

  28.      cacheKey

    :

    cacheKey

  29.    

    }).

    orderBy

    (

    "cacheDate"

    ,

    "desc"

    ).

    get

    ()

  30.    

    if

    (

    cacheData

    .

    data

    .

    length

    !==

    0

    &&

  31.      

    ((

    date

    .

    getTime

    ()

    -

    cacheData

    .

    data

    [

    0

    ].

    cacheDate

    )

     

    <

    1800

    *

    1000

    ))

    {

  32.      res

    =

    JSON

    .

    parse

    (

    cacheData

    .

    data

    [

    0

    ].

    content

    )

  33.    

    }

    else

    {

  34.      res

    =

    await fetchDevelopers

    ({

    language

    ,

    since

    });

  35.      await db

    .

    collection

    (

    "developers"

    ).

    add

    ({

  36.        data

    :

    {

  37.          cacheDate

    :

    date

    .

    getTime

    (),

  38.          cacheKey

    :

    cacheKey

    ,

  39.          content

    :

    JSON

    .

    stringify

    (

    res

    )

  40.        

    }

  41.      

    })

  42.    

    }

  43.  

    }

  44.  

    return

    {

  45.    data

    :

    res

  46.  

    }

  47. }



Markdown 解析

嗯,这是一个大坑。

在做技术调研的时候,发现小程序端 Markdown 解析主要有以下方案:



  • wxParse:作者最后一次提交已是两年前了,经过自己的尝试,也确实发现已经不适合如 README.md 的解析



  • wemark:一款很优秀的微信小程序 Markdown 渲染库,但经过笔者尝试之后,发现对 README.md 的解析并不完美



  • towxml:目前发现是微信小程序最完美的 Markdown 渲染库,已经能近乎完美的对 README.md 进行解析并展示



在 Markdown 解析这一块,最终采用的也是 towxml,但发现在解析性能这一块,目前并不是很优秀,对一些比较大的数据解析也超出了小程序所能承受的范围,还好贴心的作者 (sbfkcel) 提供了服务端的支持,在此感谢作者的努力!



  • Markdown解析云函数


  1. const

    Towxml

    =

    require

    (

    "towxml"

    );

  2. const

    towxml

    =

    new

    Towxml

    ();

  3. // 云函数入口函数

  4. exports

    .

    main

    =

    async

    (

    event

    ,

    context

    )

    =>

    {

  5.  

    const

    {

    func

    ,

    type

    ,

    content

    }

    =

    event

  6.  let res

  7.  

    if

    (

    func

    ===

    "parse"

    )

    {

  8.    

    if

    (

    type

    ===

    "markdown"

    )

    {

  9.      res

    =

    await towxml

    .

    toJson

    (

    content

    ||

    ""

    ,

    "markdown"

    );

  10.    

    }

    else

    {

  11.      res

    =

    await towxml

    .

    toJson

    (

    content

    ||

    ""

    ,

    "html"

    );

  12.    

    }

  13.  

    }

  14.  

    return

    {

  15.    data

    :

    res

  16.  

    }

  17. }



  • markdown.js组件


  1. import

    Taro

    ,

    {

    Component

    }

    from

    "@tarojs/taro"

  2. import

    PropTypes

    from

    "prop-types"

  3. import

    {

    View

    ,

    Text

    }

    from

    "@tarojs/components"

  4. import

    {

    AtActivityIndicator

    }

    from

    "taro-ui"

  5. import

    "./markdown.less"

  6. import

    Towxml

    from

    "../towxml/main"

  7. const

    render

    =

    new

    Towxml

    ()

  8. export

    default

    class

    Markdown

    extends

    Component

    {

  9.  

    static

    propTypes

    =

    {

  10.    md

    :

    PropTypes

    .

    string

    ,

  11.    base

    :

    PropTypes

    .

    string

  12.  

    }

  13.  

    static

    defaultProps

    =

    {

  14.    md

    :

    null

    ,

  15.    base

    :

    null

  16.  

    }

  17.  constructor

    (

    props

    )

    {

  18.    super

    (

    props

    )

  19.    

    this

    .

    state

    =

    {

  20.      data

    :

    null

    ,

  21.      fail

    :

    false

  22.    

    }

  23.  

    }

  24.  componentDidMount

    ()

    {

  25.    

    this

    .

    parseReadme

    ()

  26.  

    }

  27.  parseReadme

    ()

    {

  28.    

    const

    {

    md

    ,

    base

    }

    =

    this

    .

    props

  29.    let that

    =

    this

  30.    wx

    .

    cloud

    .

    callFunction

    ({

  31.      

    // 要调用的云函数名称

  32.      name

    :

    "parse"

    ,

  33.      

    // 传递给云函数的event参数

  34.      data

    :

    {

  35.        func

    :

    "parse"

    ,

  36.        type

    :

    "markdown"

    ,

  37.        content

    :

    md

    ,

  38.      

    }

  39.    

    }).

    then

    (

    res

    =>

    {

  40.      let data

    =

    res

    .

    result

    .

    data

  41.      

    if

    (

    base

    &&

    base

    .

    length

    >

    0

    )

    {

  42.        data

    =

    render

    .

    initData

    (

    data

    ,

    {

    base

    :

    base

    ,

    app

    :

    this

    .

    $scope

    })

  43.      

    }

  44.      that

    .

    setState

    ({

  45.        fail

    :

    false

    ,

  46.        data

    :

    data

  47.      

    })

  48.    

    }).

    catch

    (

    err

    =>

    {

  49.      console

    .

    log

    (

    "cloud"

    ,

    err

    )

  50.      that

    .

    setState

    ({

  51.        fail

    :

    true

  52.      

    })

  53.    

    })

  54.  

    }

  55.  render

    ()

    {

  56.    

    const

    {

    data

    ,

    fail

    }

    =

    this

    .

    state

  57.    

    if

    (

    fail

    )

    {

  58.      

    return

    (

  59.        

    <

    View

    className

    =

    "fail"

    onClick

    ={

    this

    .

    parseReadme

    .

    bind

    (

    this

    )}>

  60.          

    <

    Text

    className

    =

    "text"

    >

    load failed

    ,

    try

    it again

    ?

    Text

    >

  61.        

    View

    >

  62.      

    )

  63.    

    }

  64.    

    return

    (

  65.      

    <

    View

    >

  66.      

    {

  67.        data

    ?

    (

  68.          

    <

    View

    >

  69.            

    <

    import

    src

    =

    "../towxml/entry.wxml"

    />

  70.            

    <

    template is

    =

    "entry"

    data

    =

    "{{...data}}"

    />

  71.          

    View

    >

  72.        

    )

    :

    (

  73.          

    <

    View

    className

    =

    "loading"

    >

  74.            

    <

    AtActivityIndicator

    size

    ={

    20

    }

    color

    =

    "#2d8cf0"

    content

    =

    "loading..."

    />

  75.          

    View

    >

  76.        

    )

  77.      

    }

  78.      

    View

    >

  79.    

    )

  80.  

    }

  81. }



Redux



其实,笔者在该项目中,对 Redux 的使用并不多。一开始,笔者觉得所有的界面请求都应该通过 Redux 操作,后面才发现,并不是所有的操作都必须使用 Redux,最后,在本项目中,只有获取个人信息的时候使用了 Redux。


  1. // 获取个人信息

  2. export

    const

    getUserInfo

    =

    createApiAction

    (

    USERINFO

    ,

    (

    params

    )

    =>

    api

    .

    get

    (

    "/user"

    ,

    params

    ))

  1. export

    function

    createApiAction

    (

    actionType

    ,

    func

    =

    ()

    =>

    {})

    {

  2.  

    return

    (

  3.    params

    =

    {},

  4.    callback

    =

    {

    success

    :

    ()

    =>

    {},

    failed

    :

    ()

    =>

    {}

    },

  5.    customActionType

    =

    actionType

    ,

  6.  

    )

    =>

    async

    (

    dispatch

    )

    =>

    {

  7.    

    try

    {

  8.      dispatch

    ({

    type

    :

    `

    $

    {

    customActionType  

    }

    _request

    `,

    params

    });

  9.      

    const

    data

    =

    await func

    (

    params

    );

  10.      dispatch

    ({

    type

    :

    customActionType

    ,

    params

    ,

    payload

    :

    data

    });

  11.      callback

    .

    success

    &&

    callback

    .

    success

    ({

    payload

    :

    data

    })

  12.      

    return

    data

  13.    

    }

    catch

    (

    e

    )

    {

  14.      dispatch

    ({

    type

    :

    `

    $

    {

    customActionType  

    }

    _failure

    `,

    params

    ,

    payload

    :

    e

    })

  15.      callback

    .

    failed

    &&

    callback

    .

    failed

    ({

    payload

    :

    e

    })

  16.    

    }

  17.  

    }

  18. }

  1.  getUserInfo

    ()

    {

  2.    

    if

    (

    hasLogin

    ())

    {

  3.      userAction

    .

    getUserInfo

    ().

    then

    (()=>{

  4.        

    Taro

    .

    hideLoading

    ()

  5.        

    Taro

    .

    stopPullDownRefresh

    ()

  6.      

    })

  7.    

    }

    else

    {

  8.      

    Taro

    .

    hideLoading

    ()

  9.      

    Taro

    .

    stopPullDownRefresh

    ()

  10.    

    }

  11.  

    }

  12. const

    mapStateToProps

    =

    (

    state

    ,

    ownProps

    )

    =>

    {

  13.  

    return

    {

  14.    userInfo

    :

    state

    .

    user

    .

    userInfo

  15.  

    }

  16. }

  17. export

    default

    connect

    (

    mapStateToProps

    )(

    Index

    )

  1. export

    default

    function

    user

    (

    state

    =

    INITIAL_STATE

    ,

    action

    )

    {

  2.  

    switch

    (

    action

    .

    type

    )

    {

  3.    

    case

    USERINFO

    :

  4.      

    return

    {

  5.        

    ...

    state

    ,

  6.        userInfo

    :

    action

    .

    payload

    .

    data

  7.      

    }

  8.    

    default

    :

  9.      

    return

    state

  10.  

    }

  11. }


目前,笔者对 Redux 还是处于一知半解的状态,嗯,学习的路还很长。

4. 结语篇

当 Gitter 第一个版本通过审核的时候,心情是很激动的,就像自己的孩子一样,看着他一点一点的长大,笔者也很享受这样一个项目从无到有的过程,在此,对那些帮助过笔者的人一并表示感谢。

当然,目前功能和体验上可能有些不大完善,也希望大家能提供一些宝贵的意见,Gitter 走向完美的路上希望有你!

最后,希望 Gitter 小程序能对你有所帮助!

本文为机器之心经授权转载,

转载请联系原作者获得授权



?------------------------------------------------


加入机器之心(全职记者 / 实习生):hr@jiqizhixin.com


投稿或寻求报道:

content

@jiqizhixin.com


广告 & 商务合作:bd@jiqizhixin.com




0