mmag

ハマったことメモなど

dev.toコードリーディング会に参加した

smarthr.connpass.com

行ってきました。十数人で各々が好きなところから読み始めて、最後に見所や気づきを喋る流れでした。initializersを読んでいく人やモデル中心に見ていく人など様々。近日中に全員のメモが公開されるとのことですので、そのときはリンク追記します。会場を提供してくださったSmartHRさんありがとうございました。


追記

追記ここまで


以下は自分のメモ。やはりdev.toと言えば爆速なので、CSSをheadタグに埋め込んでいたり、キャッシュってどうやってんだろうなーといったところを中心に読みました。最後の所感にも書きましたが、爆速サイトをつくるためのイケてる最強プラクティスが詰まったコードを期待していた割に、泥臭さ100%のコードやら結構ひどいメソッドたちやらが溢れていて、歴史に勝てるものはおらんのじゃ...という気持ちになりました。usersテーブル辺りを見ると共感が得られると思います。やはり銀の弾丸は無く、歯を食いしばってひとつずつ問題をなぎ倒していくしか道は無いのです...。

なお、ハイライトは http://sushi.to というサイトが発見された瞬間でした。


headタグへのCSS埋め込み

Service Worker

  • serviceworker-rails使ってる
  • service-worker.js.erb
    • インストール時に最小限必要なjs, css, 画像類, offline.htmlを取得してキャッシュに入れてる
    • GET以外、通知の取得はスルーしてネットワークへ
    • 割とキャッシュに入れてない
  • serviceworker-companion.js
    • 割と普通
    • Homeにインストールの結果をGAに送っていた

どうやってCDNキャッシュしてるか

  • bodyのdata属性にユーザ情報を埋め込んでいるが、これを非同期にやっているのでCDNにキャッシュできる

initializePage.js.erb

function initializePage(){
  initializeLocalStorageRender();
  initializeStylesheetAppend();
  initializeFetchFollowedArticles();
  callInitalizers();
}

function callInitalizers(){
  initializeLocalStorageRender();
  initializeBodyData();

initializeBodyData/async_info/base_dataを叩いてbodyのdataに入れてる。入れたらlocalStorageにも入れてる。次回からはlocalStoreageから読み出してbodyのdataに入れてる。getUserData()は15秒間待ってる。

base.js.erb

function mouseoverListener(e) {
  if ($lastTouchTimestamp > (+new Date - 500)) {
    return // Otherwise, click doesn't fire
  }

  var a = getLinkTarget(e.target)

  if (!a || !isPreloadable(a)) {
    return
  }

  a.addEventListener('mouseout', mouseoutListener)

  if (!$delayBeforePreload) {
    preload(a.href)
  }
  else {
    $urlToPreload = a.href
    $preloadTimer = setTimeout(preload, $delayBeforePreload)
  }
  getImageForLink(a);
}

マウスホバーでの先読みしてる。スマホtouchstartListener

所感

CSS埋め込みとか、CDNエッジキャッシュするための工夫とか、もうちょっと高度な謎テクノロジーであのパフォーマンスを実現していると思っていたけど、読んでみたら想像を絶する泥臭さだった。総じて、この量のjsをテストほとんど無しで本当に書いたの...という気持ち。


PhoenixでPage Specific JavaScript

と言ってもすでにインタネットに情報があります。

medium.com

まずはviewモジュール名(@view_module)とテンプレート名(@view_template)からjsファイルのパスが導出できるように取り決めしておき、bodyタグのdata属性にそのパスを吐き出しておく。で、DOMContentLoadedイベントでそのパスを読み、webpackのrequire.contextでガバっと読み込んだ中から実行するものを決める、という寸法。

ただこのページにあるコードではjsのファイル名がイマイチで、もうちょい良くなりそう。

def unique_view_name(view_module, view_template) do
  [_elixir, _app | context_controller_template] = view_module
    |> Phoenix.Naming.humanize()
    |> String.split(".")

  [action, "html"] = view_template |> String.split(".")

  Enum.join(context_controller_template ++ [action], "_")
end

例えばUserControllershowアクションとかなら、userview_showになるし、Admin.UserControllerindexアクションとかなら、admin_userview_indexになる。ただしこれだとjsファイルを同じディレクトリに全部並べなくてはならず、Elixirのコードと同じような階層構造をつくって整理できない。また、jsのファイル名はkebab-caseかCamelCaseがおそらく一般的で、少なくとも_区切りは好まれない。あとuserviewのようにviewが全部に付くのもどうにかしたい。

ということで書いてみたのが以下。

def js_module_path(view_module, view_template) do
  [_elixir, _my_app_web | context_view] = view_module
    |> Atom.to_string()
    |> String.split(".")

  context_names = context_view |> List.delete_at(-1)
  resource_name = view_module |> Phoenix.Naming.resource_name("View")
  template_name = view_template |> String.replace_suffix(".html", "")

  (context_names ++ [resource_name, template_name])
  |> Enum.map(&(&1 |> Phoenix.Naming.underscore |> String.replace("_", "-")))
  |> Enum.join("/")
end

Phoenix.Naming便利。これならマックス意地悪な入力を与えても

js_module_path(
  MyAppWeb.GreatShop.TopSeller.FancyItemView,
  "member_only_index.html"
)
#=> "great-shop/top-seller/fancy-item/member-only-index"

という出力になっていい感じ。

ちなみに、これまでPhoenixのモジュールバンドラはbrunchがデフォルトでしたが、v1.4.0からwebpackになります。


全部書いておく。

<!-- app.html.eex -->
<html>
  ...
  <body data-js-path="<%= js_module_path(@view_module, @view_template) %>">
    ...
  </body>
</html>
# lib/my_app_web/views/layout_view.ex

defmodule MyAppWeb.LayoutView do
  use MyAppWeb, :view

  def js_module_path(view_module, view_template) do
    [_elixir, _my_app_web | context_view] = view_module
      |> Atom.to_string()
      |> String.split(".")

    context_names = context_view |> List.delete_at(-1)
    resource_name = view_module |> Phoenix.Naming.resource_name("View")
    template_name = view_template |> String.replace_suffix(".html", "")

    (context_names ++ [resource_name, template_name])
    |> Enum.map(&(&1 |> Phoenix.Naming.underscore |> String.replace("_", "-")))
    |> Enum.join("/")
  end
end
// assets/js/app.js

import css from "../css/app.css"
import "phoenix_html"

import pageScript from './pages/index'

window.addEventListener("DOMContentLoaded", () => {
  pageScript()
})
// assets/js/pages/index.js

const requireContext = require.context(".", true, /\.js$/)
const modules = {}

requireContext
  .keys()
  .filter(filename => filename !== './index.js')
  .forEach(filename => {
    const path = filename.replace('./', '').replace('.js', '')
    modules[path] = requireContext(filename)
  })

export default function() {
  const path = document.querySelector('body').dataset.jsPath
  const fun = modules[path]

  if (fun && fun.default) {
    fun.default()
  }
}

GenStageのcastやcallもdispatchするんですね

github.com

以前ふむふむ眺めただけだったので触ってみたら微ハマりした。

defmodule Producer do
  use GenStage

  ...

  def enqueue(item) do
    GenStage.cast(__MODULE__, {:enqueue, item})
  end

  def handle_cast({:enqueue, item}, state) do
    {:noreply, [item], state}
  end
end

こういうとき、Producer.enqueue(:something)するとconsumerへ1つイベントが流れていく。

こうやって見ると普通だけど、consumerへイベントを流す指示ができるのはhandle_demandだと思いこんでいて、最初は↓の実装をしてた。

defmodule Producer do
  use GenStage

  ...

  def enqueue(item) do
    GenStage.cast(__MODULE__, {:enqueue, item})
  end

  def handle_cast({:enqueue, item}, queue) do
    {:noreply, [], :queue.in(item, queue)}
  end

  def handle_demand(demand, queue) do
    case :queue.out(queue) do
      {:empty, queue} ->
        {:noreply, [], queue}
      {{:value, item}, queue} ->
        {:noreply, [item], queue}
    end
end