| @技術/プログラミング

Vim の設定に関してはペパボ時代に同僚のみなさん( glidenote さん、 banyan さん、 linyows さん)から色々学んでほぼほぼ不満がない状態になっている。正規表現拡張の eregex.vimrails.vimprojectionist.vimvim-surround など tpope プラグインと Shougo プラグイン( unite, vimfiler あたり)で普段のユースケースはカバーされているが、 EasyMotion というプラグインの存在と、同じ人がメンテしている incsearch.vim というものがあることを知って入れていみた。カーソル移動を楽にする Vim プラグインのようだ。これまで ⇧wf⌃d などで高速移動はできるといえばできるが、空白や単語の切れ目ではない位置を狙った移動では結局 hl を連打してしまっていた。 EasyMotion は冒頭の数文字を検索すると飛び先をアンカー表示してくれて、高速にファイル内を移動できるというもの。 Unite のファイル内ジャンプ版という感じかな。

さらに調べると incsearch.vim の方は Vim 本体に取り込まれたみたいで必要ないらしいのだが、 で検索にヒットしたカ所を移動したりする機能は incsearch.vim の機能っぽいので両方セットで使ってみることにした。 EasyMotion は incsearch と組み合わせて使うことでかなり便利になるっぽい(ブリッジに haya14busa/incsearch-easymotion.vim が必要だ)。こんな感じ↓(画像は incsearch-easymotion のリポジトリから拝借)

incsearch-easymotion demo

| @技術/プログラミング

7 年間眠っていたブランチを起こして、 Lokka の ActiveRecord 化に取り組み始めた。元のブランチは hrysd さんが取り組んでいたやつだ。

現在の master の内容を取り込むのが大変だった。 active-record ブランチでは ActiveRecord 化と同時に様々な改良・改変が行われていて、 master の内容と思い切りコンフリクトするものがあったりして、コンフリクトの解消作業はかなり大変だった。

active-record の大きな変更点は以下。

  1. カスタムパーマリンク機能の削除
  2. 「もっと読む」機能の削除
  3. カテゴリーをネストさせる機能の削除
  4. ユーザー認証方法の変更(カラムの追加)

このうち 1 と 2 は削除された機能を復活させた。自分が使っていてなくなると困るし、特にカスタムパーマリンクは既存サイトでこの機能を使っているところがデッドリンクだらけになって散々な目に遭ってしまう。 4 に関しても、 master の認証方法と互換性を持たせないと既存ユーザーがログインできなくなるので古い認証方法でもログインできるようにした。

3 に関しては WordPress との互換性を考えると必要かもしれないが、自分で使ってなくてユースケースが思い浮かばないのでいらないかなという感じがする。そもそも Lokka は WordPress キラーとなるべく Fjord 社内で作られ始めたと認識しているが、 WordPress は相変わらず元気だし Lokka の利用状況的にも WordPress alternative を目指す必要はないと思う。

そのほか、 rake db:delete が動かなかったのを直したり bundle update をしてぶっ壊れたところを直したり、デフォルト以外のテーマが ActiveRecord 化してなかったのを対応させたり( dm-pagination から kaminari へ移行)して ActiveRecord 5 で概ね動くところまで持ってくることができた。

ActiveRecord は良くできていて、 DataMapper だと難しかった JOIN した上での集計クエリなどが書きやすい。ドキュメントが山ほどあるのもよい。 DataMapper は情報が少ないのが一番つらかった。一方で DataMapper だと気にする必要がなかった N+1 問題を自分で解決する必要がある。 View でうかつに参照するテーブルのデータを増やすと N+1 問題が発生して途端にパフォーマンスが劣化する。

また、誰がどんな DB で利用するかわからない状況で db/schema.rb を git で追跡してよいものかというのもひかっかる。 ActiveRecord を使う以上、 migration と schema.rb からは逃げられないのだが、 MySQL で使う人も PostgreSQL で使う人も SQLite で使う人もいて、それぞれの DB でマイグレーションを実行するごとに異なる schema.rb が吐き出されるので git で追跡すべきではないのではないかと思う。どんなデータベースで利用されるかを意識せずに開発できる、という点では DataMapper の方が CMS 開発向きだったと思う。

以前の Lokka であればあまり Ruby 知らない人でもとりあえず git clone して自分の好みのテーマを追加して Heroku に push すれば動かせたが、 ActiveRecord 化することで N+1 問題など Rails に強くないと触りにくい感じになってしまった。ただ、 Sass は Ruby を捨てて C に移行したし、 Slim なんかも JavaScript フロントエンド技術の盛り上がりの陰で開発は停滞している。こういう時勢になってくるとフロントエンドに強いマークアップエンジニア兼ウェブデザイナー的な人が Ruby 製の CMS を使う動機はなくなってしまう。 CMS を使ったサイト構築でも Sass や Slim を使って HTML コーディングの生産性を上げ、 Heroku を使って簡単に deploy できる、というのが komagata さん達が最初に想定してた Lokka のユースケースだと思うけど、 JavaScript によるフロントエンド技術が強力になりすぎて、生産性の高いフロントサイド開発のために Ruby を経由する必要がなくなってしまった。


これから Lokka はどうあるべきなのだろうか。モダンなフロントエンドフレームワークは強力だ。否が応でも JAMStack に対応していくしかないだろうと思う。つまり Sinatra で作るのは API (と管理画面)だけになり、フロントエンドは React や Vue.js で作るべきだろう。ちょっとしたサイトを JAMStack で構築したいが、 API に良いのがない、とはいえ Rails は使いたくない、というケースで Lokka を使うという感じだろうか。ただ、いまは Firebase なんかもあるのでそもそも API を自前で持つ必要はないのかもしれない。どのみちかなりニッチなユースケースになるだろう。

ちなみにこのブログの Archive ページは中途半端ながら React で作っていて割といい感じに動いている。 ActiveRecord 化が済んだら React でサイト全体を作り直してみたい。

| @技術/プログラミング

Chart

Rechars という React のチャートライブラリを利用して、 Archive ページにカテゴリーごとに記事を集計してグラフ化する機能を作った。

グラフの Bar にカーソルを載せると Tooltip が表示されて、具体的な件数がわかる。

Chart Tooltip

カテゴリごとに表示・非表示を切り替えることも可能。グラフ下のカテゴリー名( Legend )をクリックして切り替えられる。

Chart Show-Hide Toggle

ただし残念なことに Bar を非表示にしたときに Legend の表示を変化させるのが難しくてできていない。

仕事で使ってる Looker とか Redash であれば Legend をクリックして表示・非表示を切り替えることができ、それに連動して Legend の色をトーンダウンさせたりする機能が付属しているが、利用した Recharts にはその機能がなかった。 Bar の表示・非表示切り替えも標準サポートされていなかったので、 GitHub の Issue の情報を頼りに無理矢理実装した。

コードはこんな感じ。結構汚い Hack で、 Bar の表示・非表示を、表示用のキー文字列に空白を追加するかしないかで切り替えている。

  // クリックされたアイテムが `this.state.disabled` 配列の中にすでに存在していれば除外し、
  // 存在してなければ追加する
  selectBar(event) {
    let dataKey = event.dataKey.trim()
    if (this.state.disabled.includes(dataKey)) {
      this.setState({ disabled: this.state.disabled.filter(item => item !== dataKey) })
    } else {
      this.setState({ disabled: this.state.disabled.concat([dataKey]) })
    }
  }

  render() {
    return (
      <ResponsiveContainer height={500}>
        <BarChart
          data={this.state.data}
          margin={{
            top: 20, right: 20, left: 0, bottom: 20,
          }}
          style={{ fontSize: '14px' }}
        >
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis dataKey="year" />
          <YAxis />
          <Tooltip labelStyle={{ color: '#000', fontWeight: 'bold' }} itemStyle={{ margin: '0 2px 0 4px', padding: '0' }} />
          {// Legend クリック時のコールバックに `this.selectBar` を指定する }
          <Legend onClick={this.selectBar} />
          {/*
            `this.state.categories` 配列と `this.state.disabled` 配列の内容を比較し、
            `this.state.disabled` に追加済のカテゴリーは dataKey に空白を追加することで非表示に
          */}
          {this.state.categories.map((category, index) => {
            let dataKey = this.state.disabled.includes(category) ? category + " " : category
            let color = this.colors[index % this.colors.length]
            return(<Bar key={index} dataKey={dataKey} stackId="a" fill={color} />)
          })}
        </BarChart>
      </ResponsiveContainer>
    );
  }

| @技術/プログラミング

DataMapper のドキュメントを見たくてググったが出てくるのは Stack Overflow ばかりで公式サイトが検索結果に出てこない。 GitHub の DataMapper のリポジトリ( Archive されている)経由で見に行ってみると、なんと ROM ( Ruby Object Mapper ) のページにリダイレクトされた。

ROM は Hanami で使われる ORM で、 DataMapper よりもさらに ActiveRecord と使い心地が異なる。

Qiita の以下の記事を読むと使い方のイメージが湧く。

軽くてシンプルなのだろうがだいぶ特殊だ。

Lokka の使い手は少なくとも Heroku が使える人で、そういう人ならば ActiveRecord の方が Rails の本やドキュメントで学びやすいはずだ。というわけで早めに、真剣に ActiveRecord への移行を考えなければならない。

| @技術/プログラミング

react-select-before-after.jpg

一個前の記事のキャプチャにあるように、従来テキストリンクだった年とカテゴリーの選択をセレクトボックスにした。 git log -S したところ去年( 2019 年)の 11 月頃に変更を行ったみたいだ。もうそろそろ 2020 年になろうとしていて、 2005 年からやっていて年の数が 16 個になろうとしていてさすがに多すぎると思ったので整理のためにセレクトボックス化してコンパクトにした。

利用したのは React Select というパッケージで、色々カスタマイズできるみたいだけど面倒だったので素のまま使ってる。

Archives ページのリファクタリング のときのように、子コンポーネントのイベントをトリガーに親コンポーネントの setState() を呼び出すような作りになっている。作るときは結構難儀したけどおかげでスマートフォンで見たときも年やカテゴリーだけでファーストビューが埋まるということがなくなった。

react-select-smartphone-view.jpg

現在のコードをまるっと貼り付けるとこんな感じ↓。

子コンポーネント(年のセレクトボックス)

import React, { Component } from 'react'
import ReactDOM from 'react-dom'
import Select from 'react-select'
import PropTypes from 'prop-types'
import { withRouter } from 'react-router-dom'

import history from './history'

class YearSelect extends Component {
  constructor(props) {
    super(props)
    this.state = {
      data: [],
      selectedOption: null
    }
    this.handleChange = this.handleChange.bind(this)
  }

  static propTypes = {
    match: PropTypes.object.isRequired,
    location: PropTypes.object.isRequired,
    history: PropTypes.object.isRequired
  }

  async loadYearSelectFromServer() {
    const request = await fetch('/archives/years.json')
    const response = await request.json()
    this.setState({ data: response })
  }

  componentDidMount() {
    this.loadYearSelectFromServer()
  }

  handleChange(selectedOption) {
    const year = selectedOption ? selectedOption.value : null
    if (year) {
      this.props.history.push(`/archives/${year}`)
    } else {
      this.props.history.push("/archives")
    }
    this.setState(
      { selectedOption },
      () => { this.props.update(year) }
    )
  }

  render() {
    const options = this.state.data.map(year => {
      return { value: year, label: year }
    })
    return (
      <div className="year-list">
        <Select
          value={this.state.selectedOption}
          onChange={this.handleChange}
          options={options}
          placeholder="Year"
          isClearable
        />
      </div>
    )
  }
}

const YearList = withRouter(YearSelect)

export default YearList

親コンポーネント( App.js )

import React, { Component } from 'react'
import { BrowserRouter as Router, Switch, Route, Redirect } from 'react-router-dom'

import YearList from './YearList'
import CategoryList from './CategoryList'
import Archives from './Archives'

class App extends Component {
  constructor(props) {
    super(props)
    this.state = {
      category: null,
      year: null,
      length: 0
    }
    this.updateCategory = this.updateCategory.bind(this)
    this.updateYear = this.updateYear.bind(this)
    this.setLength = this.setLength.bind(this)
  }

  updateCategory(category) {
    this.setState({ category })
  }

  updateYear(year) {
    this.setState({ year })
  }

  setLength(length) {
    this.setState({ length })
  }

  render() {
    return(
      <Router history={history}>
        <div className="archive-filter">
          <YearList update={this.updateYear} />
          <CategoryList update={this.updateCategory} activeCategory={this.state.category} />
          <div className="entry-length"><p>{this.state.length} entries</p></div>
        </div>
        <Switch>
          <Route
            exact path="/archives"
            render={(props) =>
              <Archives
                category={this.state.category}
                setLength={this.setLength}
                {...props}
              />
            }
          />
          <Route
            path="/archives/:year(\d{4})"
            render={(props) =>
              <Archives
                category={this.state.category}
                setLength={this.setLength}
                year={this.state.year}
                {...props}
              />
            }
          />
        </Switch>
      </Router>
    )
  }
}

export default App

| @技術/プログラミング

Archives ページ記事絞り込み

Archives ページに記事数を表示するようにした。一昨日、年を未指定の場合は全件読み込むようにしたので Archives ページを訪れると総記事数が確認できるようになった。年やカテゴリーで絞り込むと絞り込み後の件数を確認できる。カテゴリーごとの記事の偏り具合が確認できて便利。

さらにカテゴリーのセレクターは記事件数順でソートするようにした。これまでは categories.id でソートしていたため、件数の多いカテゴリーがセレクトボックスの下の方に埋もれたりしていてあまり良い絞り込み体験ではなかった。

こうしてカテゴリーごとに記事を絞り込んでみてみると、意外と違和感のある分類になっていることに気がつく。記事のカテゴリーに関してはあまり意味ないというか分類が難しいものもあると思う。ブログというカテゴリーに分類してみたものの、実際の記事内容はプログラミング寄りだったりして、読者が読みたい記事を探す際の指針とはなり得ないなと思う。 TF-IDF のような機械的な関連判定が記事のグルーピングには有効だと思う。勝手にカテゴリー付けしてくれたら便利だと思う。

技術的な話をすると、選択したカテゴリーで記事を絞り込む都合上、件数のカウントはフロントエンドで行う必要があった。実は 2 年くらい前の Archives ページにも絞り込み時の件数表示機能はあったのだが、当時は React の機能を活用できておらず、記事の絞り込みは Virtual DOM の操作ではなく CSS の display: none; でやっていて jQuery と大差なかった。記事の絞り込みを React の機能を使ってやるようになってから件数の表示ができていなかったのをできるようにしたのが今回というわけだ。

お爺さんコンポーネントで定義した関数をひ孫コンポーネントまで伝搬させて、結果が変わるとお爺さんコンポーネントの state が書き変わる。ちょっと setState() を使いすぎていて微妙に画面がチラつく( state を更新するたびにレンダリングが行われるため)。もっとうまいやり方があるのかもしれない。

これをやるにあたってこれまで関数として実装していたコンポーネントをクラス化した。 return() の前処理でやっていたゴニョゴニョを componentDidMount() に移す必要が出たが、実は componentDidUpdate() でもやらなければならなかったり、 componentDidMount() 時にはまだ props が渡されてきてないので componentDidUpdate() で実行する必要があったりと、結構ハマりどころがあった。コンポーネントはなるべくクラス化するのが格好いいような気がしていたけど、凝ったことをやる必要がないコンポーネントは関数のままの方がよいと思った。この辺も改めて React はよくできていると感心する。

| @技術/プログラミング

外出自粛かつ自分自身にコロ助疑惑があったのでずっと部屋に閉じこもってサイトのデザインをいじってた。

2020-04-07 デザインアップデート.jpg

上がこれまでで、左下が今後、右下が画像がワイドな場合のバージョン。これまで横幅 1280px 想定にして画像も横幅 1280px のサイズで表示するようにしていた。

ただしこれだと写真は見やすくても文字が読みにくい。人間の目は 1280px 繰り返し左右に移動させるのには適していないようだった( N=1 )。

そういうわけでまたまた cho45 さんのブログのレイアウトをパクって、文章は横幅短めに、写真はでかいサイズで表示するようにした。

文章部分の幅を 800px にして、画像を読み込んだときに一定の条件にマッチしたら写真の横幅を 1280px で表示するようにした。 margin-left: -250px; してるのがミソ。横幅 800px だと大分文章は読みやすいし、でかい写真は大きく見えて便利。

@media screen and (min-width: 1422px) {
  #content #main article .body > p {
    img[class~="large"] {
      width: $content-max-width;
      max-width: $content-max-width;
      margin-left: -250px;
    }
  }
}

JS はこんな感じのコードを書いた。

const checkImageSize = (target) => {
  if (typeof target === 'undefined') {
    return;
  }
  const width = target.naturalWidth;
  const height = target.naturalHeight;
  const isPhoto = RegExp('(lh3\.googleusercontent\.com|\.jpe?g$)').test(target.src);
  if (width > 1279 && width > height && isPhoto) {
    target.classList.add('large');
  }
}

const lazyImageObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const lazyImage = entry.target;
      lazyImage.src = lazyImage.dataset.src;
      lazyImage.addEventListener('load', (event) => checkImageSize(event.target));
      lazyImageObserver.unobserve(lazyImage);
    }
  });
});

const selectors = "#content article .body img, #content article .similar img";
const lazyImages = [].slice.call(document.querySelectorAll(selectors));

if ("IntersectionObserver" in window) {
  for (let image of lazyImages) {
    const src = image.src;
    image.dataset.src = src;
    if (lazyImages.indexOf(image) === 0) {
      const promise = new Promise(resolve => resolve(image));
      promise.then(checkImageSize).catch(setTimeout(checkImageSize, 100))
      continue;
    }
    image.src = "";
  };

  lazyImages.forEach(lazyImage => {
    lazyImageObserver.observe(lazyImage);
  });
}

画像の読み込みが起こったタイミングにフックして画像のサイズチェックを行い、条件にマッチしたら img タグに class を追加し、 CSS のメディアクエリと合わせ技で大きく表示するようにしている。以前やった画像の遅延読み込みのコードを改良した。

その他、 HTML 5 対応が中途半端だったのでマークアップを見直して、適度に <header><secition><article><footer> を使ってマークアップし直した。 このせいだと思うけど Google Adsense の自動広告が差し込まれる位置が変わった。これまでヘッダーの下に入り込んでいたやつが <header><article> の間に入るようになった。

今後も外出禁止が続いたらひたすら自分のブログをいじってしまう予感。こういうエネルギーを仕事とか個人サービス開発とかに当てられるとよいのだろうけどなかなかそういう方向には向かない。