みんなの「作ってみた」

Vue/Mobx + Elixir/Phoenix で匿名SNS「つぶやきや」を作ってみた

2019/01/13

tamanugi
tamanugi

作ったもの

https://tubuyakiya.gigalixirapp.com/

みんな大好きいらすとやさんの人物いらすとを使って、
Twitter風につぶやけるサービスです :sparkles:

アバターは「変更」ボタンを押すとランダムで変わります(10種類ぐらいしかないです・・・)

WebSocketを使っており、

他の人のつぶやきがリアルタイムで表示されたり Likeされていくと、アニメーション付きで数字がリアルタイムで増えていきます

使った技術

フロントエンド

  • Vue.js (Vue CLI 3)
  • Mobx
  • rxjs
  • bulma

バックエンド

  • Elixir/Phoenix

ホスティング

  • gigalixir

参考: 【Gigalixir編①】Elixir/Phoenix本番リリース: 初期PJリリースまで

プロジェクト構成

Vue, Phoenixそれぞれ分けてつくるのではなく、Phoenixの中にVueプロジェクトを作成する構成で
今回は作りました

$ mix phx.new hogehoge
$ cd hogehoge
$ rm -rf assets
$ vue create asssets

vue.config.js を作成して、 vueのビルド先をphoenixのstaticフォルダに向けます

assets/vue.config.js
module.exports = {
  outputDir: '../priv/static/js',
  assetsDir: '../',
  filenameHashing: false
}

あとは app.html.eex<div id="app"></div> を追加すればOKです。
開発のときは mix phx.serveryarn build --watch をそれぞれ別窓で実行すれば
いい感じに自動更新も走るので開発しやすかったです。

状態管理

フロントエンドでは状態管理のライブラリとしてMobxを使用しています。
特に深い理由はなく、Mobxがどんなものか勉強してみたかったので。

状態管理のライブラリが欲しくなる場面として、複数コンポーネント感での
同じ状態をいじりたいというのがあると思ってますが、
今回は以下のように1番親のコンポーネントで初期化して、
子コンポーネントに渡しています

<template lang="pug">
  .section
    .board
      .input-area
        InputArea(:vm = "vm")
      transition-group(name="list-complete", tag="div")
        .media-wrapper(v-for="message in vm.messages", :key="message.id", class="list-complete-item")
          MediaObject(:vm="vm", :message="message")
    AvatarSelector
</template>

<script lang="ts">
...

@Observer
@Component({
  components: {
    MediaObject,
    InputArea,
    AvatarSelector,
  },
})
export default class Home extends Vue {
  private vm = new MessageViewModel()

  ...
}
</script>

これが正解なのかどうなのかはよくわかりません。。。
main.tsで初期化して vueオブジェクトにぶちこむ方がいいのかなって
思ったりもします。

mobx自体はものすごくシンプルなので、個人開発レベルでは気軽に導入できていいなって感じました。
vuexはいろいろと用意するものが多く、大変なイメージがあるので。。。

WebSocketへの接続

WebSocketへの接続はPhoenix.jsを使ってやっています。
Phoenix使うとWebSocektまわりがすごい簡単で、感動しますね :sparkles:

viewmodel.ts
  @observable messages: Message[] = []
  private socket = new Socket('/socket')
  private channel: Channel | undefined = undefined

  private shoutSubject = new Subject<Message>()
  private likeSubject = new Subject<Message>()
  private likeAnimationSubject = new Subject<Message>()

  constructor() {
    this.socket.connect()
  }

  private channelObs() {
    return new Observable<Channel>(observer => {
      const channel = this.socket.channel('room:lobby')
      channel.join()
        .receive('ok', resp => {observer.next(channel)})
        .receive('error', resp => {
          console.error(resp)
        })
    })
  }

  private joinRoom() {
    this.channelObs()
      .subscribe(channel => {
        channel.on('shout', res => { this.shoutSubject.next(res) })
        channel.on('like', res => { this.likeSubject.next(res) })

        this.channel = channel
      })
  }

  @action.bound subscribe() {
    this.joinRoom()

    this.shoutSubject.subscribe(res => {
      console.log(res)
      res.hearted = false
      this.messages = [res, ...this.messages]
    })

    this.likeSubject.subscribe(({id, heart}) => {
      const idx = this.messages.findIndex(x => x.id === id)
      const targetMessage = this.messages[idx]
      targetMessage.heart = heart

      if (!targetMessage.hearted) {
        this.likeAnimationSubject.next(targetMessage)
      }
    })

...

rxjsとchannelまわりをうまく組み合わせてsubscribeしたらchannelに参加しにいくように
したいのですが、なかなか難しいですね :sweat_drops:

channelのイベントごとにSubjectを用意するのはなかなかよさそうな気がしています

つぶやきのアニメーション

他の人のつぶやきが少しアニメーションするのは Vue.jsのTransitionを使いました
参考: https://jp.vuejs.org/v2/guide/transitions.html

<template lang="pug">
...
      transition-group(name="list-complete", tag="div")
        .media-wrapper(v-for="message in vm.messages", :key="message.id", class="list-complete-item")
          MediaObject(:vm="vm", :message="message")
...
</template>

<style lang="stylus">
.list-complete-item 
  transition: all 1s;
  display: inline-block;
  margin-right: 10px;
  width 100%

.list-complete-enter, .list-complete-leave-to
/* .list-complete-leave-active for below version 2.1.8 */ 
  opacity: 0;
  transform: translateY(-60px);

.list-complete-leave-active 
  position: absolute;
</style>

ほぼ公式からのコピペでそれっぽいアニメーションが実装できたので、とても楽でした。

Likeのアニメーション

Likeのアニメーションは以下のページからいただきました
https://ics.media/entry/15970

.hearted というクラスを付け替えてアニメーションを実行しています

クラスの付替えはMobx内で以下のようにRxjsのSubjectを使ってやっています

    this.likeAnimationSubject
      .pipe(
        tap(m => {m.hearted = true}),
        delay(500),
      )
      .subscribe(message => {
        message.hearted = false
      })

アニメーション完了後にまたLikeが飛んできたらアニメーションを行いたいため、
500msほどディレイさせてクラスを外しています

ホスティング

ホスティングはgigalixirというElixir向けのPaasを使いました。
herokuみたいに使えるのですごい楽ですね :sparkles:

ただし、いろいろと設定ファイル等が必要でした

デフォルトだとElixirのバージョンが1.5.xで、Phoenix1.4で動かすには少し古いため、
Elixirのバージョンを指定する必要がありました

elixir_buildpack.config
elixir_version=1.7.4
erlang_version=21.0

Nodeのバージョンはデフォルトで6.9ぐらいでこれまた古かったので、以下のファイルでバージョンを指定

phoenix_static_buildpack.config
# Clean out cache contents from previous deploys
clean_cache=false

# We can change the filename for the compile script with this option
compile="compile"

# We can set the version of Node to use for the app here
node_version=10.14.2

# We can set the version of NPM to use for the app here
npm_version=6.4.1

# Remove node and node_modules directory to keep slug size down if it is not needed.
remove_node=false

gigalixirにあげたあとに静的ファイルをビルドする必要があるので、compileファイルを作成して
ビルドコマンドを指定

compile
cd $phoenix_dir/assets
yarn build

cd $phoenix_dir
mix "${phoenix_ex}.digest"

最後に

Vue/Mobxの勉強したくて、色々触っているうちにBulmaのMediaObjectに気づいて、
「Twitter作れるじゃん!」ってなって今回作成してみました :sparkles:

またVue + Phoenixの組み合わせはWebアプリを作成するには最高の組み合わせだと感じました!
今年は色々と作ってみたいと思います :thumbsup:

なにか意見などありましたら、コメントいただけると嬉しいです!