TochkaというSSIサーバー作った話

前書き

今回は、gulp.jsはまだオワコンちゃうでって話の続きというか
昔、作ったgulpfile一枚物の
ソースコードぐっちゃぐちゃなSSIサーバーがあって
これをgulp-babelで整理してみました。

gulp-babelの使用の一例紹介という感じで書きます。

リポジトリこちら

依存関係

"devDependencies": {
  "@babel/cli": "^7.5.5",
  "@babel/core": "^7.5.5",
  "@babel/preset-env": "^7.5.5",
  "@babel/preset-es2017": "^7.0.0-beta.53",
  "@babel/register": "^7.5.5",
  "babel-preset-latest": "^6.24.1"
},
"dependencies": {
  "@babel/polyfill": "^7.4.4",
  "browser-sync": "^2.26.7",
  "connect-ssi": "^1.1.1",
  "gulp": "^4.0.2",
  "gulp-plumber": "^1.2.1",
  "gulp-sass": "^4.0.2",
  "gulp-sourcemaps": "^2.6.5",
  "serve-static": "^1.14.1"
}

タスクの構成

タスクの構成はこんな感じ

.
├── generate
│   ├── css.js
│   └── index.js
└── server
    └── index.js

トランスパイルなどのファイル書き出し系タスク

sassをcssにトランスパイルして
distに書き出すタスク
「generate/css.js」

import { src, dest } from 'gulp'
import sourcemaps from 'gulp-sourcemaps'
import sass from 'gulp-sass'
import plumber from 'gulp-plumber'

const path = {
  src: './src/**/*.scss',
  dist: './dist',
  reject: '!./src/**/_*.scss'
}


export default (done) => {

  src([path.src, path.reject])
  .pipe(plumber())
  .pipe(sourcemaps.init())
  .pipe(sass({ outputStyle: 'expanded' }))
  .pipe(sourcemaps.write('./.maps'))
  .pipe(dest(path.dist))

  done()
}


/srcディレクトリ以下の
トランスパイルしない、その他のファイルを
distに書き出すだけのタスク
cssのタスクもインポートして
generateパッケージとしてエクスポート
「generate/index.js」

import { src, dest } from 'gulp'
import css from './css'

const path = {
  src: './src/**/*',
  dist: './dist',
  reject: ['!./src/**/*.scss', '!./src/**/_*.scss']
}

const other = (done) => {

  src([ path.src, ...path.reject ])
  .pipe(dest(path.dist))

  done()
}

export default {
  css,
  other
}

サーバー起動、ブラウザリロード、ファイル監視タスク

サーバーの起動やブラウザのリロード、ファイルの監視などは
「server/index.js」にまとめました。
observeタスクでファイルのgenerateも行うので
generateパッケージもインポートして使っています。
そして、サーバー起動とファイル監視のタスクは
エクスポートする。とそんな感じです。

import { watch, series } from 'gulp'
import browserSync from 'browser-sync'
import serveStatic from 'serve-static'
import connectSSI from 'connect-ssi'

import generate from '../generate'

const path = {
  docRoot: './dist',
  docRootPwd: `${__dirname}/dist`
}

//サーバー起動
const start = (done) => {
  browserSync.init({
    server: {
      baseDir: path.docRoot,
      middleware: [
        connectSSI({
          baseDir: path.docRootPwd,
          ext: ".html"
        }),
        serveStatic(path.docRootPwd)
      ]
    }
  })

  done()
}

//ブラウザーリロード
const reload = (done) => {
  browserSync.reload()
  done()
}

//ファイル監視
const observe = (done) => {

  watch('./src/**/*.scss')
  .on('change', series(generate.css, reload))

  watch(['./src/**/*', '!./src/**/*.scss', '!./src/**/_*.scss'])
  .on('change', series(generate.other, reload))

  done()
}


export default {
  start,
  observe
}

gulpfile.babel.jsにタスクを読み込む

旧、gulpfile.jsに当たる
gulpfile.babel.jsは、
たったこれだけ、めちゃくちゃシンプルです。

import { task, series, parallel } from 'gulp'
import server from './tasks/server'
import generate from './tasks/generate'

task('default',
  series(
    parallel(
      generate.css,
      generate.other
    ),
    parallel(
      server.start,
      server.observe
    )
  )
)

試してみたい方は
こちらリポジトリをクローンして
使ってみてください。
ちなみにgulpはグローバルインストールしなくとも
npm startで起動できます。

まとめ

nuxt.jsとかgridsome.jsなどの
フレームワークが存在している時代に
SSIサーバーとか要らないだろうと思われるかもですが
webアプリ開発はまだしも
webサイト案件はまだまだ、htmlを量産するような案件もあります。
それが、大手ほどその傾向が強い

大手は、効率よりも人海戦術主義という感じなんでしょうかね...
このTochkaは、後々は、sitemapと言うか、サイトの仕様書を読み込んで
自動で、htmlを大量書き出しするタスクも追加する予定です。

ejsとか使いたいところなんですが
html量産するような案件ってデザイナーとかがコーディングに関わっている案件が多いので
sassぐらいがせいぜい限界...

そのため、ejsの導入は無理かなと
ちなみに、Tochkaの由来は
まるで、地獄の戦場とも言える
旧式大規模開発の現場のトーチカとなればと...
そういう悲しい由来です。
マジ、html量産開発死ぬわ

【2019年最新版】macにanyenvでnode.js環境を構築する

前書き

macOS Catalina から
デフォルトシェルがbashからzshになったので
自分用のメモも含めて
Macでのanyenv環境 + node.js環境の構築方法
書き留めておきます。

anyenvをアカウントルートにクローン

まずは、anyenvをOSアカウントのルートにクローンします。

git clone https://github.com/anyenv/anyenv.git ~/.anyenv

.bash_profileではなく.zshrcでパスを通す。

macOS Catalinaからは、zshなので
.bash_profileでパスを通そうとしても機能してくれませんので
.zshrcにパスを通す記述をします。
その前にそもそも、.zshrcというファイルがないのでアカウントルートで
touchコマンドを叩いてやります。

touch .zshrc

.zshrcファイルが作れたら
パスを通す記述と
anyenv initの記述を.zshrcに追加します。

echo 'export PATH="$HOME/.anyenv/bin:$PATH"' >> ~/.zshrc
echo 'eval "$(anyenv init -)"' >> ~/.zshrc
exec $SHELL -l

anyenvにupdateサブコマンドを追加

続いてanyenvで管理しているnodenvや、pyenv、rbenvなどを
一括でアップデートしてくれるサブコマンドプラグインを追加します。

git clone https://github.com/znz/anyenv-update.git "$(anyenv root)/plugins/anyenv-update"

「anyenv update」と叩くと
こんな感じになればOK

anyenv update
Updating 'anyenv'...
Updating 'anyenv/anyenv-update'...

nodenvをインストール

anyenvでnodenvをインストール

anyenv install nodenv
exec $SHELL -l

ちなみにanyenv使っていると
「npm i -g」で
グローバルにパッケージインストールとかした時も
やたらと「exec $SHELL -l」は使うので
「exec $SHELL -l」は.zshrcに「xsh」とかでalias作っとくのがオススメです。

node.jsをインストールする前にdefault-packagesを設定しておく

nodenvのdefault-packagesというファイルに
グローバルにインストールしておきたいパッケージを登録しておくと
別のバージョンのnode.jsをインストールした際、
グローバルパッケージのインストールも同時にやってくれます。

違うバージョンのnode.jsをインストールしても、すぐに前使っていたバージョンと同じ環境が整うので
ぜひ、登録しておきましょう。

まずは、nodenvルートにdefault-packagesというファイルを作ります。

touch $(nodenv root)/default-packages

必要なパッケージをdefault-packagesファイルに追記
下記は一例

echo 'npm-check-updates' >> $(nodenv root)/default-packages
echo '@vue/cli' >> $(nodenv root)/default-packages
echo 'firebase-tools' >> $(nodenv root)/default-packages

node.jsをインストール

default-packagesを設定できたら
nodenvでお好みのバージョンのnode.jsをインストールしていきましょう。

# node.jsのバージョン一覧表示
nodenv install -l
# node.jsインストール
nodenv install 10.16.3

まとめ

以上が
macOS Catalinaでのanyenv環境構築方法です。

ちなみに、下記のようなエラーが出たらxcodeのインストールが必要です。

xcrun: error: invalid active developer path (/Library/Developer/CommandLineTools)

下記のコマンドを実行しましょう。

xcode-select --install

macOS Catalinaにしたらviコマンドが死んだ件

前書き

昨日に引き続いて
なんと今日も、macOS Catalinaでトラブルが

なんとvi、vimコマンドどちらもまともに編集できない!

もうこんな超、頻繁に使う
重要な重要な
国宝級コマンドにトラブル発生とは

もうマジで「macOS Catalina」
というか「zsh
勘弁して...

とある記事ではzshbashの上位互換って情報、目にしたで
ホンマなんか?

とりあえず何が起こったか?

OSのデフォルトシェルがbashからzshに変わって
具体的に何が起こったかと言うと

viコマンドでファイルを開き

「i」インサートモードに切り替える
そして適当な文字を入力する。

編集の内、新たに文字を入力したり
その時入力した文字は「delete」キーで削除できる


しかし、、、
ファイルを一旦「:wq」で保存し
再度開く
再度インサートモードにして適当な文字を打つ
文字を打つことはできる
そして、その時入力した文字も消せる

じゃあ何が問題なの?というと
先ほど保存した時に入力した内容に対して「delete」キーが効かない

これだと、
一度保存した内容を一部書き換えたかったら
いちいち、テキストエディタで開いて編集しないといけない...

いや、マジで勘弁してくれ!!
面倒くさ過ぎ!

じゃあvi使う時だけbash使うとかは?

まぁこれも思いついて試してみた
思惑としては

デフォルトシェルを一旦bashに切り替えた上で
viを利用するaliasとか
そんな感じのことできないかなと

こちらもまぁまぁ奮闘したけどできませんでした。。。

解決方法

色々、苛立ちながらも調べていくと
こんな記事が
https://bacchi.me/vim/backspace-not-working/

ということで早速
.vimrcを編集!
下記を追加&ターミナル再起動!

set backspace=indent,eol,start

すると見事bashの時と同じ挙動のvi、vimコマンドとなりました。

まとめ

macOS Catalina」は本当、色々変わっていて
まぁまだ致命的トラブルなんてのは無いですが
小さなトラブルはチラホラありますね。

ただ、OSアカウントとAppleIDの統合はセキュリティ的にかなりナイスな
アップグレードだとは思います。

なので、なんだかんだ今回のOSは少し気に入っているかもしれないです。

あと、最後にもう一つ
bashの時はよく使ったであろう
whichコマンドですが、
こちらもbashと仕様がかなり異なってしまっています。

bashのwhichコマンドに近い事をしてやるには

type -p <任意のコマンド>

としてやると良いです。

macOS Catalinaにアップグレードしたらanyenvが使えなくなった件

前書き

macOS 最新「Catalina」出ましたね。
読み方は「カタリナ」です。
なんか女神っぽい名前...

さぞかし、僕らを幸せへと誘ってくれるのだろうと
期待して早速インストールしました。

OSアカウントが、WindowsのようにAppleIDと統合されたようで
安全性が増した気がします。

しかし、不具合は無いのかと思っていたら早速anyenvが使えなくなる
トラブルにぶち当たったので記事書きます。

npmコマンドが「not found」になったことで発覚

さて、OSも最新に出来たし
試したいnodeパッケージあったし試してみるか!と
最新OS環境で気持ちよく意気揚々と

git clone ~~~~

そして、

npm install

すると...

zsh: command not found: npm

ふぁっ!?
npmが無いだと!?
まぁ以下試すまでもなく壊滅なのは想像出来たものの
とりあえず、確認してみると...

node -v
zsh: command not found: node
which nodenv
nodenv not found
which anyenv
anyenv not found

案の定、壊滅...
実は、このトラブルの前にも別のトラブルを解決したばかりだったのもあって

ヤバい...アップグレードするんじゃなかったか?
と思ってしばし放心...

しかし、よく見ると

zsh: command not found: node

ん?「bash」じゃなくて「zsh」?

デフォルトshellがbashからzshに変わっただと!?

えっ!?何?
「Catalina」になって
bashzshに変わったんか!?

そんな根本的なとこ変えてもうたんか!?

と半ば愕然としながらググる
こんな記事が
https://news.mynavi.jp/article/osxhack-241/

一応ターミナルの環境設定のとこも見てみると
https://i.imgur.com/e7Fgk4N.png

......
「Catalina」って
女神じゃなく
魔女の名前だったのかな...
(カリフォルニア州の島の名前です。)

あれ?zshになったってことは、.bash_profileとかは?

いや、待てよ
anyenvって.bash_profileに
こういうの書くよな...

export PATH="$HOME/.anyenv/bin:$PATH"
eval "$(anyenv init -)"

ってzshなんだから.bash_profile機能して無いだけやないか!!
ということで

アカウントルートに
.zshrcを作成してこいつを書いてやると
無事動きました。

export PATH="$HOME/.anyenv/bin:$PATH"
eval "$(anyenv init -)"

うむ!
やはり「Catalina」は女神だ!
(カリフォルニア州の島の名前です。)

まとめ

anyenvはかなり重宝してるので
zsh: command not found: npm
と出た時は焦りました。
anyenv使えないならアップグレードするんじゃなかった!
と後悔しかけましたが
なんのことはない。

デフォルトshellがbashからzshになったのなら
.bash_profileとかも
bash用のファイルなので当然、zshには適応されない。

なので
.zshrcに書いてやれば良い
ただそれだけのことでした。

ただ、.zsh_profileだと適応してくれないので注意

gulp.jsはまだオワコンちゃうでって話

前置き

ふと、タスクランナーのgulp.jsが何年前からあるのか気になり
調べてみました。

ですが残念ながらwikiには、v4.0系のリリース時期しか出てませんでした。
しかし、最新の4.0系でも2017年、もう2年も前なんですね。

それではgulpの初回リリースはいつなのだろうかと
もう少し調べてみたところ

chromeの期間指定ツールで検索かけただけなので全く正確な情報ではないのですが
gulpに関する日本語の記事は一番古いので2010年頃からありました。

日本語情報が出回るのには時間差がある事を考えると
gulpは恐らく10年以上前からあったのでしょう。

ものすごい、古株ですね。

最近、なんとなく感覚的に、もうそろそろgulpはオワコンかな?
と感じていたのですが
10年も前からあったとしたらその可能性も高いですね。

しかし、もうオワコン疑惑もあるgulpですが
es6+で書ければ、まだまだ現場で活躍できるツールになるのでは?
と思ったので
babelを導入してgulpを若返らせてみました!

サンプルはまとめの最後にリポジトリのリンク記載してます。

兎にも角にも、まずは必要な依存関係をインストール

依存関係はこんな感じです。
とりあえず、テスト用にsassをトランスパイルする
タスクを作ってみようかと
gulp-plumberは、
確かsassの構文が間違っていた時にエラーでプロセスが停止するのを防ぐためのプラグインだったかと思います。

"devDependencies": {
  "@babel/cli": "^7.6.2",
  "@babel/core": "^7.6.2",
  "@babel/preset-env": "^7.6.2",
  "@babel/preset-es2017": "^7.0.0-beta.53",
  "@babel/register": "^7.6.2",
  "babel-preset-latest": "^6.24.1"
},
"dependencies": {
  "@babel/polyfill": "^7.6.0",
  "gulp": "^4.0.2",
  "gulp-plumber": "^1.2.1",
  "gulp-sass": "^4.0.2",
  "gulp-sourcemaps": "^2.6.5"
}

あと、これはまぁどっちでも良いのですが、
いちいち「npm i -g gulp」でgulpコマンド入れなくても良いように
npm scriptsに下記も追記しときます。

"scripts": {
  "start": "node node_modules/gulp/bin/gulp"
}

es6+版gulpに必要なファイル

プロジェクトルートに
「babel.config.js」と
「gulpfile.js」の代わりに、「gulpfile.babel.js」が必要です。
「babel.config.js」は「.babelrc」でも良いようですが、
公式では「babel.config.js」の方が推奨のようだったので
「babel.config.js」にします。

各ファイルの中身ですが
babel.config.jsがこちら
多分、もっと便利に細かい設定できるんだろうけど、とりあえずはこんだけの設定で

const presets = [
  [
    '@babel/preset-env', {
      targets: {
        node: 'current'
      }
    }
  ],
]

module.exports = { presets }


gulpfile.babel.jsはこんな感じです。
かつてのgulpfileからは考えられないほどスッキリ!
まぁ今回、sassのトランスパイルだけなんですが...(汗)

import { task, series, parallel } from 'gulp'
import sass from './tasks/sass'

task('default',
  series(
    parallel(
      sass.generate,
      sass.observe
    )
  )
)

各タスクはtasksディレクトリ内で切ってしまう。

今回のサンプルはもうsassだけなので
「tasks/sass」のindex.jsにまとめてしまってます。
結果的に、index.jsだけで収まってしまっているので
「tasks/sass.js」でも良いですね。

「sass/index.js」の中身はこんな感じです。

import { src, dest, watch, series } from 'gulp'
import sourcemaps from 'gulp-sourcemaps'
import sass from 'gulp-sass'
import plumber from 'gulp-plumber'

const path = {
  src: './src/sass/**/*.scss',
  dist: './dist/css/',
  reject: '!./src/sass/**/_*.scss'
}

const message = (done) => {
  console.log('scss -> css converted!')
  done()
}

const generate = (done) => {

  src([path.src, path.reject])
  .pipe(plumber())
  .pipe(sourcemaps.init())
  .pipe(sass({ outputStyle: 'expanded' }))
  .pipe(sourcemaps.write('./.maps'))
  .pipe(dest(path.dist))

  done()
}

const observe = (done) => {

  watch([path.src])
  .on('change', series(generate, message))

  done()
}

export default {
  generate,
  observe
}

index.js内で、ソースコードが肥大化して荒れそうな場合は?

es6+で書けるので
例えば、「sass/index.js」の
generateの処理が肥大化した場合

「sass/generate.js」というモジュールを作ってやると良いですね。
ファイルの中身はこんな感じですかね?

import { src, dest } from 'gulp'
import sourcemaps from 'gulp-sourcemaps'
import sass from 'gulp-sass'
import plumber from 'gulp-plumber'

const path = {
  src: './src/sass/**/*.scss',
  dist: './dist/css/',
  reject: '!./src/sass/**/_*.scss'
}

export default (done) => {

  src([path.src, path.reject])
    .pipe(plumber())
    .pipe(sourcemaps.init())
    .pipe(sass({ outputStyle: 'expanded' }))
    .pipe(sourcemaps.write('./.maps'))
    .pipe(dest(path.dist))

  done()
}

generateタスク専用のモジュールなんで
export default に直接入れてやればオッケーです!

「sass/index.js」の方では
下記のようにgenerateモジュールをインポートしてやって
最後に、エクスポートしてやれば良いですね。

import generate from './generate'

...中略

export default {
  generate,
  observe
}

まとめ

いかがでしたでしょうか?
gulpと言うとこれまではgulpfile.jsが
かなり汚染されてしまい
ソースコードが荒れてしまうなんて事も、多々あったと思いますが
このように、babelを使ってes6+スタイルで書いてやれば
かなりスッキリさせる事ができます。

調べていると、
今はタスクランナー、バンドラーなんかは色々、あるようで
新参も数々現れている中、
古株のgulpが、まだまだ現役だ!
とまでは言いませんが
まぁオワコンって言い切ってしまうほど貧弱という事も無いのではないでしょうか?

こちらにes6+スタイルのgulpのリポジトリあるので
よかったらお試しください。

CSSって実はモジュールシステムが表現できるって話

前置き

css....
エンジニアは大嫌いですよね。
今でこそ、UIフレームワークがあるからいいですが
昔、自分で書くってことをやっていた時は、
本当IEに匹敵するほど嫌いでした。

さて、そんなエンジニアに嫌われ者なCSSですが
実は、node.jsやpython
importだとかexportだとかプログラミング言語にある
モジュールシステムのような物を表現する事ができるんです。

と大層なことを言いましたが
UIフレームワークが数数多存在している昨今
もはや、ネタです。

へぇ、なるほどCSSってこんな事も表現できたんだくらいに受け止めて下さい。

CSSのクラス名がプログラミング言語の関数の様だったらいいのに...

CSSを自力で書いていくような案件がある度に思います。

「この、今htmlタグのclass属性に
書いているクラス名がプログラミング言語の関数の様だったら...」

こんな感じに

<div class="grid(4,10px,30px)"></div>

ん?待てよ
出来ないのか?

そりゃCSSプログラミング言語では無いから
sassでループさせて必要なパターンを全て書き出しておく必要はあるんだろうけど...
そもそもCSSのクラス名で使える文字、使えない文字ってどういう感じなんなんだろうか?

CSSのクラス名って大概どんな文字でも使える!?

そう思って色々調べてみることに
するとこんな記事が
https://iwb.jp/css-id-class-name-zenkaku-kigou/

なんと、CSSのクラス名って日本語も使えるんだとか
マジか...

そんで記事を読み進めると
一つの希望の光が

.C\+\+ {
  color: #F00;
  font-size: 3em;
}

なんと「+」などの記号も「\」をつけてやると使えるとのこと

CSSのクラス名に記号を使ってみる

それじゃ早速CSSのクラス名に記号使ってみよう
とりあえずhtml用意

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>demo</title>
  <link rel="stylesheet" href="./main.css">
</head>
<body>
  <h1>テスト</h1>
</body>
</html>

すると現時点でこんな感じ


https://i.imgur.com/D83OxgL.png

これをとりあえず赤文字に変えてみる
クラス名はまぁオブジェクト指向っぽく
desktop.text(red,5rem)とかでいってみようかと

.desktop\.text\(red\,5rem\){
  color: #f00;
  font-size: 5rem;
}

さて、ではこのクラスを
htmlタグに適応させます。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>demo</title>
  <link rel="stylesheet" href="./main.css">
</head>
<body>
  <h1 class="desktop.text(red,5rem)">テスト</h1>
</body>
</html>

ブラウザで確認して見ると...


https://i.imgur.com/UTeCp2A.png

おぉ!見事スタイルが適応されています。
ということはCSSオブジェクト指向や関数の形式で書こうと思えば書ける
と言うことです。

モジュールシステムを再現してみる

ここまでくればプログラミング言語のモジュールシステムを再現するのも
そう無理な発想でも無いです。

まず、node.jsとかのimport

import hoge from 'hoge'

これは、まぁCSSファイルでimportするという訳ではなく
htmlにimportするという発想ですね。
これは単純にlinkタグで読み込むだけです。
最後のまとめのところで紹介しているサンプルから抜粋ですが
こんな感じ

<link rel="stylesheet" type="text/css" href="/assets/css/modules/index.css">
<link rel="stylesheet" type="text/css" href="/assets/css/modules/desktop/grid.css" media="screen and (min-width: 768px)">
<link rel="stylesheet" type="text/css" href="/assets/css/modules/desktop/spacer.css" media="screen and (min-width: 768px)">
<link rel="stylesheet" type="text/css" href="/assets/css/modules/mobile/grid.css" media="screen and (max-width: 767px)">
<link rel="stylesheet" type="text/css" href="/assets/css/modules/mobile/spacer.css" media="screen and (max-width: 767px)">

ルールとしては
modules/パッケージ/モジュール
っていう感じです。
パッケージは必ずデバイスになります。
バイス共通はmodules/index.cssに入れる

また、モジュール.css内では一切メディアクエリを使ってはいけないというのもルールです。
必ず、linkタグの方で設定します。

余談ですが
昔は、grid.col()とかgrid.gutter()とか
バイス以外をオブジェクトとするアイディアも考えてましたが
結局、cssというのはオブジェクト指向よりも関数形式の方が相性が良く
バイス以外でオブジェクト指向で書くと整理がつかなくなり、
結局カオスになるという事が分かったので
最終的にデバイス区切りになりました。

続いてモジュール.cssの書き方ですが下記の様に先頭には必ずデバイス名(パッケージ名)を付けます。

modules/desktop/grid.css

.desktop\.grid\(#{$cols}\,#{$cgap}\,#{$rgap}\){ }

marginとかpaddingはspacerという名前でまとめてしまってもいいので
バイス名の後は必ずモジュール名でなければならないという事ではないですね。
ですがオブジェクト名は必ずデバイスです。

.desktop\.margin\(top\,#{$i * 5}\){
  margin-top: #{$i * 5}px;
}

.desktop\.padding\(top\,#{$i * 5}\){
  padding-top: #{$i * 5}px;
}

これらは、あえてnode.jsで書くとこんな感じですかね?

export const desktop = {
  margin(){},
  padding(){}
}

まとめ

以上、CSSでモジュールシステムを再現してみるとこんな感じですかね。
命名規則とかルールが書いている内に崩壊するのが
CSSと言うものですが、モジュールシステムで書くとこうも整理がつきます。
ルールも破り用がないほど堅牢です。
実際、書くときはsassのループを使うのがオススメですね。

とは言え、冒頭でも述べたように
UIフレームワークが様々あるこのご時世
所詮、ただのネタです!www

なので試してみたい方は
まぁ、こんな発想もあるんや程度に面白半分で試してみてください。

サンプルリポジトリあります。
依存関係インストールして
npm startでOK!

nuxt.js+Firebaseで認証ページを作る④

認証機能実装編

さて今回がいよいよ、ラストの認証機能実装となります。

これまでの中では一番理解が難しい回かと思いますので
理解しやすい手順で進めていきたいと思います。


今回のシリーズの全リンクはこちら

nuxt.js+Firebaseで認証ページを作る① (Firebase準備編)
nuxt.js+Firebaseで認証ページを作る② (nuxt.jsインストール編)
nuxt.js+Firebaseで認証ページを作る③ (pages&layouts準備編)
nuxt.js+Firebaseで認証ページを作る④ (認証機能実装編)

まずは認証機能実装に必要なnodeモジュールをインストールしていきましょう。

ターミナルを起動して
cd コマンドでプロジェクトルートのディレクトリまで移動しましょう。
プロジェクトルートに移動できたら下記を実行します。

npm i cookieparser js-cookie

storeにindex.jsを用意

プロジェクトルートで下記のコマンドを実行しましょう。

touch store/index.js


index.jsにstoreに必要なメソッド
state、mutations、action、gettersを
定義していきましょう。
ついでにstrictもfalseにしときます。

export const strict = false

export const state = () => ({})

export const mutations = {}

export const actions = {}

export const getters = {}

続いてユーザーidやメールアドレスなどの
認証済みユーザーの情報を入れておくstateと
stateの更新するためのmutationを作っていきましょう。

export const strict = false

export const state = () => ({
  auth: null
})

export const mutations = {
  mutateAuth(state, auth){
    state.auth = auth
  }
}

export const actions = {}

export const getters = {}

このmutateAuthメソッドで
ログインする際はユーザー情報を入れて
ログアウトする際はstate.authにnullを入れて認証情報を削除してやる事で
ログアウトができるという感じです。

ログイン画面のバリデーション機能を実装しよう。

認証画面においてメールアドレスやパスワードのバリデーションなどの機能は重要ですが
ここでは、さらっとやっていきます。
layouts/login.vueのtemplateの部分を下記のようにします。
すぐ後にloginのためのメソッドも定義するので
先にLoginボタンの@clickにloginメソッドを当てておきましょう。

<template>
  <v-app id="inspire">
    <v-content>
      <v-container
        class="fill-height"
        fluid
      >
        <v-row
          align="center"
          justify="center"
        >
          <v-col
            cols="12"
            sm="8"
            md="4"
          >
            <v-card class="elevation-12">
              <v-toolbar
                color="primary"
                dark
                flat
              >
                <v-toolbar-title>Login form</v-toolbar-title>
                
              </v-toolbar>
              <v-card-text>
                <v-form ref="loginForm" v-model="loginForm.valid">
                  <v-text-field
                    v-model="loginForm.email.model"
                    :rules="loginForm.email.rules"
                    label="Login"
                    name="login"
                    prepend-icon="person"
                    type="text"
                  ></v-text-field>

                  <v-text-field
                    v-model="loginForm.password.model"
                    :rules="loginForm.password.rules"
                    id="password"
                    label="Password"
                    name="password"
                    prepend-icon="lock"
                    type="password"
                  ></v-text-field>
                </v-form>
              </v-card-text>
              <v-card-actions>
                <div class="flex-grow-1"></div>
                <v-btn color="primary" @click="login">Login</v-btn>
              </v-card-actions>
            </v-card>
          </v-col>
        </v-row>
      </v-container>
    </v-content>
  </v-app>
</template>

バリデーションを有効にするためにデータモデルも作りましょう。
templateタグの下に下記scriptタグを追加します。

<script>
  export default {
    data() {
      return {
        loginForm: {
          valid: true,
          email: {
            model: '',
            rules: [
              v => !!v || 'メールアドレスが未入力です。',
              v => /.+@.+/.test(v) || '正しいメールアドレスを入力してください。'
            ]
          },
          password: {
            model: '',
            rules: [
              v => !!v || 'パスワードが未入力です。'
            ]
          }
        }
      }
    }
  }
</script>

いよいよログイン実装

loginメソッドを定義する前に
必要なモジュールをインポートしておきましょう。
第二回の時に作成したfirebaseのプラグインからfireAuthオブジェクト、
あとは先ほどインストールしたnodeモジュールjs-cookieをインポートします。

<script>
  import { fireAuth } from '~/plugins/firebase'
  const Cookie = process.client ? require('js-cookie') : undefined

  export default {
    data() {
      return {
        loginForm: {
          valid: true,
          email: {
            model: '',
            rules: [
              v => !!v || 'メールアドレスが未入力です。',
              v => /.+@.+/.test(v) || '正しいメールアドレスを入力してください。'
            ]
          },
          password: {
            model: '',
            rules: [
              v => !!v || 'パスワードが未入力です。'
            ]
          }
        }
      }
    }
  }
</script>

さてこれで、loginメソッドを定義する準備ができましたので
早速、loginメソッドを作って行きましょう!
ちょっと、ソースコードが長くなってしまうので
dataの中身は省略します。
dataの下にmethodsを追加してlogin()メソッドを作ってやります。

<script>
  import { fireAuth } from '~/plugins/firebase'
  const Cookie = process.client ? require('js-cookie') : undefined

  export default {
    data() {
      ...省略
    },
    methods: {
      login() {

      }
    },
  }
</script>

まず最初は兎にも角にもログインボタンがクリックされた際の
バリデーションの結果に問題ないかチェックしないといけません。
なのでまずは、バリデーションチェックしましょう。
(更にmethods内以外省略)

login() {
  if (!this.$refs.loginForm.validate()) {
    this.snackbar = true
    return
  }
}

さて次がようやくfirebaseのauthenticationを使って認証を行う部分です。
setTimeoutはあまり気にする必要はありません。
認証っぽくローダーを回すのに少しだけ遅延させているくらいの感じです。
今回はローダー使っていませんが;;

signInWithEmailAndPasswordメソッドにはフォームに入力したメールアドレスとパスワードを渡しています。
これでfirebaseにメールアドレスとパスワードが一致するユーザーが存在していれば
無事に返り値に認証情報が返ってきます。
ユーザーが存在しなかった場合やエラーの場合はnullが返ってきます。

login() {
  if (!this.$refs.loginForm.validate()) {
    this.snackbar = true
    return
  }

  setTimeout(async () => { // 非同期リクエストのタイムアウトをシミュレートします
      const auth = await fireAuth.signInWithEmailAndPassword(this.loginForm.email.model, this.loginForm.password.model).catch(err => {
        return null
      })
    }, 1000)
  }

}

ではfirebaseにちゃんと登録済みで無事に認証情報が返ってきたとして
その取得できた認証情報を先ほど実装したstoreのstate.authにいれてやりましょう。
そうする事でこの後認証後の「/admin」へページ遷移しますが
ページ遷移をしても認証情報が保持された状態になります。
Cookieにも認証情報を保存していますが、その理由はこの後すぐわかります。

login() {
  if (!this.$refs.loginForm.validate()) {
    this.snackbar = true
    return
  }

  setTimeout(async () => { // 非同期リクエストのタイムアウトをシミュレートします
    const auth = await fireAuth.signInWithEmailAndPassword(this.loginForm.email.model, this.loginForm.password.model).catch(err => {
      return null
    })

    if(auth){
      this.$store.commit('mutateAuth', auth) // クライアントレンダリング用に変更する
      Cookie.set('auth', auth) // サーバサイドレンダリングのためにクッキーに認証情報を保存する
      this.$router.push('/admin')
    }

  }, 1000)
}

それでは、firebaseに登録してあるメールアドレスとパスワードをフォームに入力してログインボタンをクリックしてみましょう。
するとちゃんと認証後の「/admin」ページへ遷移できたと思います。
https://i.imgur.com/2o3OjXD.png

しかし、ここで気になるのが、認証後の状態で「/admin/login」のURLにアクセスするとどうなるのでしょうか?
既に認証が終わっているのですから「/admin/login」にアクセスした場合「認証済み」という事で「/admin」へリダイレクトして欲しいものです。
試しに「/admin/login」へアクセスしてみましょう。

https://i.imgur.com/u6XdOYM.png

はい、見事にフラグ回収しましたね。
予想通り「/admin」へリダイレクトはしてくれませんでしたね。
それもそのはず、リダイレクトさせる処理などまだどこにも作っていませんから
リダイレクトなどしてくれるはずもありません。

という事で早速リダイレクトさせる機能を作って行きましょう。
ちなみに認証済みの際に「/admin」へリダイレクトして欲しいのももちろんですが
もっと重要なのは、認証済みでない時は「/admin」も含めて認証が必要なページにアクセスされたら
「/admin/login」へリダイレクトさせるという機能ですよね。
これがなかったらセキュリティもクソも無いガバガバのwebサービスになってしまいますから
認証済みの際のリダイレクトよりも重要な機能です。
そちらも合わせて2つのリダイレクト機能を作って行きましょう。

middlewareでリダイレクト機能を作る

認証に関するリダイレクト機能はnuxt.jsのmiddlewareを使って実装して行きます。
それでは、ターミナルでプロジェクトルートへ移動し下記のコマンドを実行しましょう。

touch middleware/authenticated.js
touch middleware/notAuthenticated.js


そしてそれぞれ
authenticated.jsにはこちらの処理を

export default function ({ store, redirect, error}) {
  // 認証必要ページ用middleware
  if (!store.state.auth) {
    return redirect('/admin/login')
  }
}

notAuthenticated.jsにはこちらを書いていきましょう。

export default function ({ store, redirect }) {
  // 認証不要ページ用middleware
  // ただし、認証済みの場合は認証ルートページへリダイレクトする
  if (store.state.auth) {
    return redirect('/admin')
  }
}

なんのことは無いです。
たったこれだけのコードです。
先ほどログインの画面でstoreのstate.authに認証情報を入れてやりましたが
要はその認証情報を取ってきて
authenticated.jsの方は認証情報が無かった場合は「/admin/login」へリダイレクト
notAuthenticated.jsの方は認証情報があった場合は「/admin」へリダイレクトさせると
単純にそれだけの内容です。

ですがここで一つ注意なのがファイル名が「authenticated」、「notAuthenticated」と過去形なので一瞬
「authenticated」の方が認証情報があった場合の処理で
「notAuthenticated」が認証情報が無かった場合の処理のように思えて紛らわしいですが逆です。

「authenticated」は認証が必要なページの場合の処理です。
なので「authenticated.js」の方が認証が必要なページに利用するmiddlewareです。

逆に「notAuthenticated」は認証が必要のないページ、つまりログイン画面で利用するmiddlewareです。
紛らわしいのですがこのファイル名は一応、公式が使っている
ファイル名なのでそうしています。

紛らわしくて嫌という事であれば、自身が分かりやすい名前に変えてもらっても良いかと思います。

それでは、middlewareの実装ができたので
それぞれ、「/admin」ページには「authenticated」を
「/admin/login」ページには「notAuthenticated」を組み込んでいきましょう。

まずは、「pages/admin/index.vue」ファイルを開いて
「layout: 'admin'」とレイアウトの指定がしてある上に
middlewareの指定も追加してやります。
これで「/admin」ページではauthenticatedミドルウェアが機能するようになります。

<template>
  <div>
    admin
  </div>
</template>

<script>
  export default {
    middleware: 'authenticated',
    layout: 'admin'
  }
</script>

<style lang="scss" scoped>

</style>

続いて「/admin/login」ページも同じようにしてやります。

<template>
  <div>
    
  </div>
</template>

<script>
  export default {
    middleware: 'notAuthenticated',
    layout: 'login'
  }
</script>

<style lang="scss" scoped>

</style>

これで、各ページにアクセスしようとした際、middlewareが認証情報の有無をチェックしてくれた上で
適切なページへリダイレクトさせてくれるはずです。

早速、再びログイン画面のフォームにメールアドレスとパスワードを入れて
ログインしてみましょう。
https://i.imgur.com/2o3OjXD.png

先ほど同様ログイン出来ました。
ログインが出来たら「/admin/login」へアクセスしてみましょう。

middlewareが認証情報の有無をチェックしてくれるはずですからログアウトしない限りは
「/admin/login」へアクセスしたら「/admin」へリダイレクトしてくれるはずです。


ところがどうでしょうか?
リダイレクトはされず先ほどと変わらず
そのまま、「/admin/login」へアクセス出来てしまったと思います。

これは実はnuxtのstoreはjavascriptレンダリングによるページ遷移の範囲では
stateを保持してくれるのですが
URL欄に直接URLを入力してのアクセスや、ページのリロード、nuxt-linkではなくaタグによるページ遷移など
通常のページ遷移ではstateを保持してくれません。

試しにmiddlewareのstore.state.authを
console.logで書き出してみてください。
middlewareはサーバー側の処理になるのでターミナル側に出力されると思いますが
恐らく、「null」となっているはずです。

nuxtのstoreに保存されたデータはブラウザのリロードなどをすると消えてしまうのです。
ではどうやってブラウザでリロードされたとしても認証情報を維持できるのか?

ここで活躍するのが、冒頭でインストールしたjs-cookieなどのCookieに関するモジュールです。
cookieparserは、cookieに保存された変数の内容をjson形式に変換してくれるモジュールです。

それでは、middlewareでもちゃんとstate.authを取得できるようにしていきましょう。

nuxtのライフサイクルを確認しておこう。

まず、middlewareで認証情報を受け取れるようにする前に
middlewareというのがnuxtのライフサイクルのどの地点で行われている処理なのか見てみましょう。
https://i.imgur.com/m2HJsjY.png
こちらがnuxtのライフサイクルです。
middlewareで認証情報を受け取れるようにするには
middlewareより後に行われる処理でユーザー情報をstate.authに保存する事をやってやっても
意味はありません。
middlewareで認証情報を受け取りたいのであればmiddlewareが呼ばれる前の処理で
storeのstate.authに認証情報を格納してやる必要があります。

middlewareが呼ばれる前の処理というと、この図を見ればわかる通り「nuxtServerInit」しかありません。
ちなみに「Request」というのはユーザーがページにアクセスするという事なのでnuxtの処理ではないです。

それでは、middlewareで認証情報を受け取るにはnuxtがnuxtServerInitという処理を行うタイミングで
storeのstate.authに認証情報を入れてやればいいということが分かりましたが
実際にはどう実装すればいいのでしょうか?

実は、nuxtのstoreのactionには特別なactionがありそれが「nuxtServerInit」というアクションになります。
ということは、nuxtServerInitはstoreのアクションに実装してやればいいということです。

nuxtServerInitに認証情報を保存するコードを書いていく

それではまず、「layouts/login.vue」のloginメソッドをもう一度確認しておきます。

login() {
  if (!this.$refs.loginForm.validate()) {
    this.snackbar = true
    return
  }

  setTimeout(async () => { // 非同期リクエストのタイムアウトをシミュレートします
    const auth = await fireAuth.signInWithEmailAndPassword(this.loginForm.email.model, this.loginForm.password.model).catch(err => {
      return null
    })

    if(auth){
      this.$store.commit('mutateAuth', auth) // クライアントレンダリング用に変更する
      Cookie.set('auth', auth) // サーバサイドレンダリングのためにクッキーに認証情報を保存する
      this.$router.push('/admin')
    }

  }, 1000)
}

Cookie.set('auth', auth)でログイン時にCookieに「auth」という名前で認証情報を保存しています。
こいつをnuxtServerInitアクションの方で受け取ってやります。

それでは「store/index.js」を開いて
まず、必要なモジュールをインポートしてやりましょう。
firebaseのプラグインからfireAuthをインポート
あと、cookieから認証情報を抽出するための「cookieparser」もインポートします。

import { fireAuth } from '@/plugins/firebase'
const cookieparser = process.server ? require('cookieparser') : undefined

export const strict = false

export const state = () => ({
  auth: null
})

export const mutations = {
  mutateAuth(state, auth){
    state.auth = auth
  }
}

export const actions = {}

export const getters = {}

必要なモジュールをインポート出来たら
actionsにnuxtServerInitを実装していきます。

import { fireAuth } from '@/plugins/firebase'
const cookieparser = process.server ? require('cookieparser') : undefined

export const strict = false

export const state = () => ({
  auth: null
})

export const mutations = {
  mutateAuth(state, auth){
    state.auth = auth
  }
}

export const actions = {
  nuxtServerInit({ commit }, { req }) {
    let auth = null
    if (req.headers.cookie) {
      //cookieのauth変数をjson形式で取得
      const parsed = cookieparser.parse(req.headers.cookie)
      try {
        // 認証情報を取得
        auth = JSON.parse(parsed.auth)
      } catch (err) {
        // cookieに認証情報がない場合はfirebase側もログアウトの処理をしておく
        fireAuth.signOut()
      }
    }
    // 認証情報がちゃんと取得できていればstate.authに認証情報が、認証情報が取得できなかった場合はnullが入る
    commit('mutateAuth', auth)
  }
}

export const getters = {}

これで、クライアント側でCookieに保存した認証情報を
サーバー側でも扱えるようになったため
middlewareでstore.state.authが取れるようになりました。
これで今度こそ認証リダイレクトがうまく動作しているはずです。

最後にログアウトボタンも作っておく

以上で認証機能実装はできているはずですが
認証機能にログアウトボタンは、もちろん必要となりますし
認証機能が本当にちゃんと実装できているのか確かめる為にも必要です。

今のままでは、ログアウトができないので
ログインした後は二度と「/admin/login」を使うことは無いという状態になってしまいます。
それでは困るので最後にログアウトボタンも作っておきましょう。

「layouts/admin.vue」を開いて
templateタグ内を下記と差し替えましょう。
単に< v-btn color="primary" @click="logout" >ログアウト< /v-btn >が加わっただけです。

<v-app dark>
  <v-navigation-drawer
    v-model="drawer"
    :mini-variant="miniVariant"
    :clipped="clipped"
    fixed
    app
  >
    <v-list>
      <v-list-item
        v-for="(item, i) in items"
        :key="i"
        :to="item.to"
        router
        exact
      >
        <v-list-item-action>
          <v-icon>{{ item.icon }}</v-icon>
        </v-list-item-action>
        <v-list-item-content>
          <v-list-item-title v-text="item.title" />
        </v-list-item-content>
      </v-list-item>
    </v-list>
  </v-navigation-drawer>
  <v-app-bar
    :clipped-left="clipped"
    fixed
    app
  >
    <v-app-bar-nav-icon @click.stop="drawer = !drawer" />
    <v-btn
      icon
      @click.stop="miniVariant = !miniVariant"
    >
      <v-icon>mdi-{{ `chevron-${miniVariant ? 'right' : 'left'}` }}</v-icon>
    </v-btn>
    <v-btn
      icon
      @click.stop="clipped = !clipped"
    >
      <v-icon>mdi-application</v-icon>
    </v-btn>
    <v-btn
      icon
      @click.stop="fixed = !fixed"
    >
      <v-icon>mdi-minus</v-icon>
    </v-btn>
    <v-toolbar-title v-text="title" />
    <v-spacer />
    <v-btn
      icon
      @click.stop="rightDrawer = !rightDrawer"
    >
      <v-icon>mdi-menu</v-icon>
    </v-btn>
  </v-app-bar>
  <v-content>
    <v-container>

      <v-btn color="primary" @click="logout">ログアウト</v-btn>

      <nuxt />
    </v-container>
  </v-content>
  <v-navigation-drawer
    v-model="rightDrawer"
    :right="right"
    temporary
    fixed
  >
    <v-list>
      <v-list-item @click.native="right = !right">
        <v-list-item-action>
          <v-icon light>
            mdi-repeat
          </v-icon>
        </v-list-item-action>
        <v-list-item-title>Switch drawer (click me)</v-list-item-title>
      </v-list-item>
    </v-list>
  </v-navigation-drawer>
  <v-footer
    :fixed="fixed"
    app
  >
    <span>&copy; 2019</span>
  </v-footer>
</v-app>

続いてscriptタグ内、
まずは、おなじみのモジュールをインポート

import { fireAuth } from '~/plugins/firebase'
const Cookie = process.client ? require('js-cookie') : undefined

そして、methodsにlogoutメソッドを定義しましょう。

methods: {
  logout() {
    Cookie.remove('auth')
    this.$store.commit('mutateAuth', null)
    fireAuth.signOut()
    this.$router.push('/admin/login')
  }
}

こちらは、loginメソッドと違いシンプルです。
cookieに保存された「auth」を削除
storeのstate.authをnullに
firebaseの方もログアウト
そして最後に「/admin/login」へリダイレクト
それだけの内容です。

まとめ

以上、がnuxt.jsとfirebase authenticationを用いた
認証画面の実装のやり方となります。
4回にも分けてやってきましたが

まぁまぁな作業量なので
おそらく、作業の区切った方が
身につきやすいだろうと思いチュートリアル風に記事を書いてみました。

このシリーズは終りですが、今後もnuxt.js周りでの発見などあれば
今回ほどのシリーズ投稿はしないかもですが
メモ的な感じで投稿していこうかと思います。

今回のシリーズで作った認証画面のサンプルはこちらにおいてあります。