20200705のJavaScriptに関する記事は30件です。

JavaScript  オブジェクトをマージする(連想配列) Object.assign()

非エンジニア一般人向け想定

JavaScript  オブジェクトをマージする (連想配列)

何かとプログラミングは横文字が多いな、、ロックダウン、オーバーシュート、クラスターってな感じで

今回はマージが重要だと思います。
マージとは、結合のことですよね~~~

連想配列は、出席番号とかニックネームつけて分かりやすくした配列だと思ってます~~。

さて、マージする方法の一つは、、

メソッド Object.assign()を使います。

const a = {a: 'a'};  
const b = {b:'b'};  
const c = Object.assign(a, b);  
console.log(c);  
c //{a: 'a', b: 'b'}

こんな感じですかね。

実際に数字とか入れてみましょう。

const animal1 = {a:'カエル',b:'ヘビ',c:'ワニ'};
const animal2 = {d:'おさる',e:'わんこ',f:'にゃんこ'};
const zoo = Object.assign(animal1,animal2);
console.log(zoo);
zoo // {a: "カエル",b: "ヘビ",c:'ワニ',d:'おさる',e:'わんこ',f:'にゃんこ'}

ちなみに

const animal3 = {a:'カエル',b:'ヘビ',c:'ワニ'};
const animal4 = {a:'おさる'};

const zoo2 = Object.assign(animal3,animal4);
console.log(zoo2);
zoo2 // {a: "おさる",b: "ヘビ",c: "ワニ",}

このように同じKeyの値があるときは第2引数のものに上書きされるようです。

Object.assign-【MDN】

 追記

 スプレッド構文

@shiracamus さんよりコメントを頂いたのでここに共有させていただきます。

> const animal1 = {a:'カエル',b:'ヘビ',c:'ワニ'};
undefined
> const animal2 = {d:'おさる',e:'わんこ',f:'にゃんこ'};
undefined
> const zoo = { ...animal1, ...animal2 };
undefined
> console.log(zoo);
{ a: 'カエル', b: 'ヘビ', c: 'ワニ', d: 'おさる', e: 'わんこ', f: 'にゃんこ' }
undefined

Object assign() を使うよりもとてもシンプルで分かりやすい。

そんで、値の上書きはされるのか、検証してみた。

const a = {a:1,b:2,c:3};
const b = {a:4,e:2,f:5};
const c = {...a, ...b};
console.log(c);
{a: 4,b: 2,c: 3,e: 2,f: 5}

こんな感じでできましたね!!! 

@shiracamusさん、ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JavaScriptでカラーバーを描画する [d3.js]

はじめに

何の需要があるかわかりませんが, JavaScriptでカラーバー(だけ)を描画したかったので, 色々模索したときのログを残しておきます.

作れるもの

こんな感じです.

image.png

d3.js と d3fc を使う

d3.jsとd3fcを利用しました. ちなみにバージョンはそれぞれd3.js(v5), d3fc(15.0.8)でした.
それぞれローカルにダウンロードするか, CDNとかでリンク貼ってください. そこら辺の説明は割愛します.

とりあえず作る

スクリプトを書く

とりあえず, テキトーにhtmlとcssを書いちゃいます.
今回は#colorbar-containerにカラーバーを入れていきましょう.
CSSは左下にカラーバー置きたいな〜ってだけなので, 無くてもいいです.

index.html
<!DOCTYPE html>
<head>
</head>
<body>
    <div id="colorbar-container"></div>
</body>
<style>

html, body {
    width: 100%;
    height: 100%;
    margin: 0;
    padding: 0;
}

#colorbar-container {
    position: absolute;
    bottom: 3rem;
    left: 2vw;

    background-color: rgba(0, 0, 0, 0.1);

    padding: 2vh 0 2vh 2vw;
    border-radius: 2rem;
}

</style>
<script>
let min=-100, max=100;
func = function (x) {
    return d3.interpolateViridis(norm(x, min, max))
};
makeColorbar(min, max, func);
</script>

JSの方を書いていきましょう.
htmlの一番下に記述しているnormmakeColorbarの中身を書いていきます.
リンクをはるか, htmlに追記してください.

norm.js
// 値を正規化(0~1の範囲に直す)
function norm(x, min, max) {
    if (x > max) return 1;
    if (x < min) return 0;
    return (x - min) / (max - min);
};
makeColorbar.js
// カラーバーの作成&配置
// minValue : カラーバーの下限値
// maxValue : カラーバーの上限値
// interpolattionFunc : 入力値をRGBの値に変換する関数
function makeColorbar(minValue, maxValue, interpolationFunc) {
    const container = "#colorbar-container";
    const addId = "colorbar-svg";
    const domain = [minValue, maxValue]; // range for colorbar

    const height = 500;
    const width = 200;

    const viewWidth = 130;
    const viewHeight = height;

    // pad colorbar
    const paddedDomain = fc.extentLinear()
        .pad([0.1, 0.1])
        .padUnit("percent")(domain);
    const [min, max] = paddedDomain;
    const expandedDomain = d3.range(min, max, (max - min) / height);

    // Band scale for x-axis
    const xScale = d3
        .scaleBand()
        .domain([0, 1])
        .range([0, width]);

    // Linear scale for y-axis
    const yScale = d3
        .scaleLinear()
        .domain(paddedDomain)
        .range([height, 0]);

    const svgBar = fc
        .autoBandwidth(fc.seriesSvgBar())
        .xScale(xScale)
        .yScale(yScale)
        .crossValue(0)
        .baseValue((_, i) => (i > 0 ? expandedDomain[i - 1] : 0))
        .mainValue(d => d)
        .decorate(selection => {
            selection.selectAll("path").style("fill", d => interpolationFunc(d, ...domain));
        });

    // Drawing the legend bar
    const legendSvg = d3.select(container).append("svg")
        .attr("height", viewHeight)
        .attr("width", viewWidth)
        .attr("viewBox", `0, 0, ${viewWidth}, ${viewHeight}`)
        .attr("id", addId);
    const legendBar = legendSvg
        .append("g")
        .datum(expandedDomain)
        .call(svgBar);

    tickValues = [...domain,
    domain[0] + (domain[1] - domain[0]) / 5,
    domain[0] + 2 * (domain[1] - domain[0]) / 5,
    domain[0] + 3 * (domain[1] - domain[0]) / 5,
    domain[0] + 4 * (domain[1] - domain[0]) / 5]

    // Removing the outer ticks
    const axisLabel = fc
        .axisRight(yScale)
        .tickValues(tickValues)
        .tickSizeOuter(0)
        .decorate((s) =>
            s.enter()
                .select("text")
                .style("font-size", "20"));

    // Drawing and translating the label
    const barWidth = Math.abs(legendBar.node().getBBox().x);
    legendSvg.append("g")
        .attr("transform", `translate(${barWidth})`)
        .datum(expandedDomain)
        .call(axisLabel);

    // Hiding the vertical line
    legendSvg.append("g")
        .attr("transform", `translate(${barWidth})`)
        .datum(expandedDomain)
        .call(axisLabel)
        .select(".domain")
        .attr("visibility", "hidden");
};

表示例

CSSで左下の方に追いやってるので, 以下のような感じになります.
また, テーマはViridisを利用してます.

cbar_00.png

簡単な説明

まず, html内の<script>タグ内ですが, 値→RGB値の変換関数を作成して, makeColorbarに渡しています.
d3のd3.interpolate~~系を用いると, 0〜1の範囲の値をRGBへ変換することができます.
今回はテーマにviridisを用いてるので, d3.interpolateViridisを用いて, RGBへと変換しています.

interpolateVirids.png

ここで, 例えば-70〜200のレンジでカラーバーを作成したい場合, この値を0〜1の範囲に直す必要がでてくるので, 今回はnormという関数を用意して, 正規化を行いました.

肝心のカラーバー作製を行っている関数makeColorbarですが, こちらを参考に作成しました.
冒頭の変数をいじれば大きさとか入れる場所などを指定できるので, 変更する場合はそこら辺を直してみてください.

カラーバーのテーマを変更する/カラーバーの色の向きを反転する

スクリプトを書く

まず, htmlの下部の<script>タグ内部を以下のように変更します.

index.html
<script>
let min=-100, max=100;
func = getInterpolationFunc(min, max, "red-blue", false);
makeColorbar(min, max, func);
</script>

JSの方を書いていきます.
htmlの追記したgetInterpolationFuncの中身を書いていきます.
こちらもリンクをはるか, htmlに追記してください.

getInterpolationFunc.js
// テーマ名を入れると対応するテーマにおけるのRGB変換関数を返す
// minValue : カラーバーの下限値
// maxValue : カラーバーの上限値
// interpolateTheme : 利用したいテーマの名前を入れる
// reverseHeatmap : カラーバーの色の向きを逆向きにする (trueで逆向き)
function getInterpolationFunc(minValue, maxValue, interpolateTheme, reverseHeatmap) {
    let f = function () { };
    switch (interpolateTheme) {
        case "red-blue":
            f = d3.interpolateRdBu;
            break;

        case "red":
            f = d3.interpolateReds;
            break;

        case "blue":
            f = d3.interpolateBlues;
            break;

        case "spectral":
            f = d3.interpolateSpectral;
            break;

        case "viridis":
            f = d3.interpolateViridis;
            break;

        case "inferno":
            f = d3.interpolateInferno;
            break;

        default:
            f = d3.interpolateSpectral;
    }

    if (reverseHeatmap) {
        return function (x) { return f(1 - norm(x, minValue, maxValue)) };
    } else {
        return function (x) { return f(norm(x, minValue, maxValue)) };
    }
};

簡単な説明

getInterpolationFuncでは, 色変換の関数を取ってきます.
色変換の関数はd3にいくつか用意されているので, それらに対してswitch文で対応しているだけです.
ちなみにd3のカラーバーのテーマはこちらに見本と一緒に載ってます.
他に好きなカラーテーマがあれば, switch文に追加すれば大丈夫です.

getInterpolationFuncの下の方で, 色方向の反転処理を書いてます. 0~1の範囲のものを1~0にしてあげればいいだけです.

(jetはこう)

動的にする

カラーバーの各パラメータを動的に変化させる

上限値/下限値, テーマ, 色の方向を動的に変化できるようにします.
JQueryを使って動的に描画していきます. JQueryもCDNなりなんなりでいれます.

値の入力とそれを受け取ることができるようにするため, htmlを以下のように変更します.
ちなみにフォームの部分はだいぶ適当です.

index.html
<!DOCTYPE html>
<head>
</head>
<body>
    <div id="colorbar-container"></div>
    <div id="input-container">
        <input type="number" value="-100" id="min-input" />
        <input type="number" value="100" id="max-input" />
        <select class="have-ul" id="theme-select">
            <option value="red-blue" selected>Red → Blue</option>
            <option value="red">Red</option>
            <option value="blue">Blue</option>
            <option value="spectral">spectral</option>
            <option value="viridis">viridis</option>
            <option value="inferno">inferno</option>
        </select>
        <input type="checkbox" id="reverse-input"/>
        <input type="button" id="decide-button" />
    </div>
</body>
<style>

html, body {
    width: 100%;
    height: 100%;
    margin: 0;
    padding: 0;
}


#colorbar-container {
    position: absolute;
    bottom: 3rem;
    left: 2vw;

    background-color: rgba(0, 0, 0, 0.1);

    padding: 2vh 0 2vh 2vw;
    border-radius: 2rem;
}


#input-container {
    position: absolute;
    top: 3rem;
    right: 2vw;

    background-color: rgba(0, 0, 0, 0.1);
}

#input-container input,
#input-container select {
    display: block;
    width: 100%;
}

</style>
<script>
$(function(){
    $("#decide-button").on("click", function() {

        // 各値の取得
        const min = parseInt($("#min-input").val());
        const max = parseInt($("#max-input").val());
        const theme = $("#theme-select").val();
        const reverse = $("#reverse-input").prop("checked");

        // カラーバーを消す
        $("#colorbar-container").empty();

        func = getInterpolationFunc(min, max, theme, reverse);
        makeColorbar(min, max, func);
    });
});
</script>

動作確認

こんな感じになります.

cbar.gif

大きさの変更

このままだと, ウィンドウサイズを変えたときに大きさが不変なので, そこら辺を直していきましょう.
makeColorbar.jsの関数に少し付け足していきます.

makeColorbar.js
// カラーバーの作成&配置
// minValue : カラーバーの下限値
// maxValue : カラーバーの上限値
// interpolattionFunc : 入力値をRGBの値に変換する関数
function makeColorbar(minValue, maxValue, interpolationFunc) {
    const container = "#colorbar-container";
    const addId = "colorbar-svg";
    const domain = [minValue, maxValue]; // range for colorbar

    // 高さを動的に変化させる 今回はウィドウサイズの38%の高さにする.
    const height = Math.floor(38 * $(window).height() / 100);
    const width = 200;

    const viewWidth = 130;
    const viewHeight = height;

    // ~~ 中略 ~~ //

    // Resizing colorbar when window size is resized
    $(window).off('resize');
    $(window).on('resize', function () {
        legendSvg.attr("height", Math.floor(38 * $(window).height() / 100));
    });
};

余談 : Jetについて

d3にJetのカラーテーマのがないっぽいので, 自作してみました.
以下のようにinterpolateJet関数を用意して, makeColorbar(-100, 100, interpolateJet)のように渡してあげれば, 描画できます. (もしくはgetInterpolationFuncのswitch文に追加してあげてください)

interpolateJet.js
//The steps in the jet colorscale
const jet_data_lin = [
    [0,0,0.5],
    [0,0,1],
    [0,0.5,1],
    [0,1,1],
    [0.5,1,0.5],
    [1,1,0],
    [1,0.5,0],
    [1,0,0],
    [0.5,0,0]
]

const jet_rgb = jet_data_lin.map(x => {
    return d3.rgb.apply(null, x.map(y=>y*255))
})

jetInterpList = new Array(jet_rgb.length-1);
for (let i=0;i<jet_rgb.length-1;i++) {
    jetInterpList[i] = d3.interpolateRgb(jet_rgb[i], jet_rgb[i+1])
}

function interpolateJet(pow) {
    if (!(0 <= pow && pow <= 1)) return "rgb(0, 0, 0)";

    const n = jet_rgb.length-1;

    var i = Math.max(0, Math.min(n - 1, Math.floor(pow *= n)));
    return jetInterpList[i](pow - i);
};

こんな感じになります.

jet.png

参考

How to create a continuous colour range legend using D3 and d3fc

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

console.logで配列やオブジェクトが省略されてしまうときはconsole.dirを使おう

console.dirの第2引数にオブジェクト{depth: null}を渡すと深い階層も省略されずに全部見れます。

Node.js console.dir

const menu = {
  main: {
    currey: 500,
    udon: 450,
    rice: 150
  },
  side: {
    soup: {
      misoshiru: {
        wakame: 250,
        asari: 250
      },
      potage: {
        corn: 250,
        potato: 250
      }
    },
    dessert: {
      icecream: {
        vanilla: 100,
        chocolate: 100
      }
    }
  }
}

console.log(menu)
/*
{
  main: { currey: 500, udon: 450, rice: 150 },
  side: {
    soup: { misoshiru: [Object], potage: [Object] },
    dessert: { icecream: [Object] }
  }
}
*/

console.dir(menu, {depth: null})
/*
{
  main: { currey: 500, udon: 450, rice: 150 },
  side: {
    soup: {
      misoshiru: { wakame: 250, asari: 250 },
      potage: { corn: 250, potato: 250 }
    },
    dessert: { icecream: { vanilla: 100, chocolate: 100 } }
  }
}
*/
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JavaScriptのIE11対応まとめ(勝手によしなにしてくれるやつ)

コピペjsしか書けないよわよわコーダーのメモ書きです。

まず確認。

IE11 で使用できる・できない JavaScript の機能

そして以下対応策①。

polyfill.io
古いブラウザでサポートされていない機能を使えるようにしてくれるコードらしいです。
polyfill.io が便利すぎた

対応策②。

Babel.io
jsのバージョンES6をES5に変換してくれるみたいです。
ES6の概要と、最新ブラウザに対応させる「Babel」の使い方

Babelはプラグインみたいですがブラウザ上で変換出来るサービスもあります↓↓↓
Babel Try it out

ただ、上記ではクラス式およびコンストラクタ?の対応が上手くいきませんでした。
今後の課題。ぬぬー

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

async/awaitとthen/catchを混合しない方がいいかもしれないという話

JavaScriptでこのようなコードを見ることがあります。

const response = await request('https://...').catch((err) => {
  logger.fatal(err);
});

意図としてはこのようなものだと推測できます。

  • 成功時はそのままresponseを使いたい
  • でも異常時はそのことをロギングしたい
  • try catchを書くのは面倒くさい?

ですがこのコードは大きな問題を孕んでいます。

問題がない場合 : 成功時

成功時は問題がありません。request()で得られた結果はそのまま定数のresponseに代入されます。

大問題の場合 : 失敗時

request()が失敗したときはpromise.catch()が呼び出されるのはお分かりいただけると思いますし、このコードを書いた人もそれを意図しているということも読み取れますが、このコードは異常終了する可能性がかなり高いです。

なんでそんなに危険なんですか

危険な理由はpromise.catch()を行った後の戻り値にあります。ただロギングをしているpromise.catch()のコールバックの戻り値はvoidです。この戻り値はresponseに代入されるためresponsevoid(undefined)となってしまいます。

もしもこの後に以下のようなコードがあった場合undefinedに対するプロパティアクセスとして落ちてしまうでしょう。

const response = await request('https://...').catch((err) => {
  logger.fatal(err);
});

return response.body;

わかりにくいこと

正常時はpromise.catch()は実行されずにawaitが行われると考えている

正常時も異常時も以下の順に処理が行われることを理解する必要があるでしょう。

  1. request()
  2. promise.catch()
  3. await

promise.catch()も必ず実行されるというのがキモです。正常時もこのメソッドは呼ばれており、その結果何もしていないということを知っておく必要があります。

promise.catch()の内部的な実装はこのようになっており、単にpromise.then()のaliasであると理解していただければわかりやすいでしょう。
(実際のPromiseはクラスではなく、関数を使った記法であると思いますが、便宜的にクラスでの記法を採用します)

class Promise {
  then(onfulfilled, onrejected) {
    // ...
  }

  catch(onrejected) {
    return this.then(null, onrejected);
  }
}

promise.then()はじつは引数が2個あり、第1引数は成功時、第2引数は失敗時の処理を意味しています。第1引数のみを書く場合がほとんどだと思いますが、第2引数に書けばその時点でのエラーハンドリングができます。

第2引数が書かれていないpromise.then()は、そのPromiserejectedになったときはエラーハンドリングを行わず次のPromiseにそのまま処理を委譲します。これが最後のpromise.catch()まで伝播した結果がPromiseのチェインで最後に見ることができるpromise.catch()です。

逆に、第1引数が書かれていないpromise.then()は成功時はハンドリングを行わず、異常時にエラーハンドリングを行うことがおわかりいただけるかと思います。

TypeScriptでは

TypeScriptはpromise.then(), promise.catch()ともに以下のようなシグネチャを持っています。

interface Promise<T> {
  then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<TResult1 | TResult2>;

  catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): Promise<T | TResult>;
}

どちらも注目していただきたいのは戻り値で、戻り値のPromiseの中の値を示すgenericUnion typesになっていることがわかります。上記JavaScriptの例をTypeScriptで実装すると戻り値responseの型はResponse | undefinedとなることがすぐにわかります。
(Responseはそのリクエストの戻り値だと思ってください)

どうすればいいですか

async/awaitthen/catchを同時に使うとこの問題が起こりやすいでしょう。そのためどちらかに統一して書くようにします。オススメはasync/awaitです。then/catchは同じ問題がこのメソッドの呼び出し側で起こりえます。

async/await

try {
  const response = await request('https://...');

  return response.body;
}
catch (err) {
  logger.fatal(err);
}

then/catch

return request('https://...')
  .then((response) => {
    return response.body;
  })
  .catch((err) => {
    logger.fatal(err);
  });

結論

TypeScriptを使うと考えあぐねることがない。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

文字数が多い時に最後に出す「...」をJSでパパッと作る。

今回は、webサイトでよく見る
「サンプルテキストです。サンプルテキストです。サンプルテキ...」の
「...」を関数で出力する方法を紹介。

DEMOはこちら。


See the Pen
pogaWpj
by 坊 拓磨 (@bo_chan6130)
on CodePen.


準備

HTML
JavaScript

できること

・ある文字数を超えると、末尾に「...」が出る。
・超えなかったら超えない範囲の文字をそのまま出力。

実装

index.html
   <p id="hoge">サンプルテキストです。サンプルテキストです。</p>
index.js
    var selectId = document.getElementById("hoge")
    var string = selectId.textContent.replace(/\s+/g,'');

    function truncate(str, len){
        return str.length <= len ? str: (str.substr(0, len)+"...");
      };
    selectId.innerHTML = truncate(string, 5);

ポイント
①任意のidを持ったタグの文章を変数stringにいれる。この際、文字列の中の不要な空白を削除したいのでreplace(/\s+/g,'')を記述。
②真ん中のfunction truncate(str,len)で出力の最大値を調整できるように関数を作成。
③最後、①を②の制限数分だけinnerHTMLで書き換え。

まとめ

cssで擬似要素使う(こっちだとタグ出力範囲での制限だけでちょい使いづらい)よりもこっちの方が早かったので、
もし使いたい方はコピペドゾー。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JSのオブジェクト型のデータを配列に変換してfilter関数を使って欲しい情報のみを抽出する

案件でAPIから受けとったオブジェクト型のデータの重複を取り除いたり欲しい情報のみを抽出するといったことをしました。
結構詰まったのでメモです。

<script>
export default {
  data() {
    return {
      obj: {
        '1': { id: 1, name: 'javascript' },
        '2': { id: 2, name: 'vue.js'},
        '3': { id: 3, name: 'react'},
        '4': { id: 4, name: 'angular'},
        '5': { id: 5, name: 'vue.js'},
        '6': { id: 6, name: 'angular'},
        '7': { id: 7, name: 'vue.js'}
      }
    }
  },
}

このようなデータがあります。
これを表示させるとこんな感じになります。

<div>
  <div v-for="(js, index) in obj" :key="index">
    {{ index }} {{ js.name }}
  </div>
</div>

スクリーンショット 2020-07-05 20.29.40.png
まあまあ普通にv-forで表示させただけのシンプルな文です。

nameが"vue.js"のものだけを表示させる

このデータの"name"が"vue.js"のもののみを抽出させたいと思います。

<template>
  <div>
    <div v-for="(js, index) in objInArray" :key="index">
      {{ index }} {{ js.name }}
    </div>
  </div>
</template>
<script>
export default {
  data() {
    return {
      obj: {
        '1': { id: 1, name: 'javascript' },
        '2': { id: 2, name: 'vue.js'},
        '3': { id: 3, name: 'react'},
        '4': { id: 4, name: 'angular'},
        '5': { id: 5, name: 'vue.js'},
        '6': { id: 6, name: 'angular'},
        '7': { id: 7, name: 'vue.js'}
      }
    }
  },
  computed: {
    objInArray () {
      this.objectOperation()
      return this.obj
    }
  },
  methods: {
    objectOperation() {
      const obj = this.obj
      const result = obj.filter((value) => {
        return value.name === 'vue.js'
      })
      this.obj = result
    }       
  },
}

しかしこれはエラーが起きてしまいます。
filter関数を使用しているところが配列ではないためです。
そこでObject.entries()の出番です。

  computed: {
    objInArray () {
      this.objectOperation()
      return this.obj
    }
  },
  methods: {
    objectOperation() {
      const arr = Object.entries(this.obj)
      console.log(arr)
      const result = arr.filter(([id, value]) => {
        console.log(id)
        return value.name === 'vue.js'
      })
      this.obj = result
    }       
  }

1行づつ説明していくと、Object.entries(this.obj)でオブジェクト型を配列形式に変換しています。
スクリーンショット 2020-07-05 20.52.20.png

arrの中はこのような感じになっています。1つの配列に7つの配列が格納されているのがわかるかと思います。
このような形にすることでfilter関数やらfind関数やらが使えるようになります。
そして先ほど配列形式に変換したarrをfilter関数を使用してvalue.namevue.jsの物のみを抽出しています。

filter関数の引数arr.filter([id, value])は配列の添字を指しており、これを記述することでid番目のvalueといったように直接指定することができます。
最後にobjをうわがいてこのスクリプトは終了です。

<template>
  <div>
    <div v-for="(js, index) in objInArray" :key="index">
      {{ index }} {{ js[1].name }}
    </div>
  </div>
</template>

上記のようにHTMLを書いてあげると下記の添付画像のようにvue.jsのみが抽出されました。
スクリーンショット 2020-07-05 21.07.56.png

{{ js[1].name }}[1]は何かどこから来たのかというと
スクリーンショット 2020-07-05 21.09.32.png
先ほどのスクリプトで上の画像のような形で配列を受け取ります。
objという配列を一件ずつ回して表示させています。
HTML上でvue.jsと表示させるにはobjの中に複数ある配列の中の1: Objectの中にname: vue.jsがあるため{{ js[1].name }}と書いてあげることで添字を直接指定して表示させています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

〔JavaScript〕連想配列の使い方

連想配列とは

JavaSciptには連想配列というものがあります。

  • 連想配列では、1つの変数で複数の「キー」と「を保持できます。
  • キーは任意の文字列を使うことができます。
  • 各項目をプロパティと呼びます。(キーのことをプロパティとも呼びます)
  • 構文は、キーと値を波括弧{}でくくります。(項目が複数の場合はカンマで区切ります。)
  • 連想配列はオブジェクトリテラルまたはハッシュとも呼ばれます。
  • 配列のarrayオブジェクトを継承していません。(arrayオブジェクトのメソッドやプロパティは使えません)

宣言の仕方

const a = {};
// または
const a = new Object();
// で連想配列を宣言する

とすることで宣言することができます。
サンプルを作ってみます。

const animals = {a1:"",a2:"",a3:""};

console.log(animals["a1"]); // 犬
console.log(animals["a2"]); // 猫
console.log(animals["a3"]); // 鳥

その他の使い方を例で説明していきます。

項目を追加する方法

const animals = {a1:"",a2:"",a3:""};
animals["a4"] = "";

console.log(animals);//{a1:"犬",a2:"猫",a3:"鳥",a4 :"熊"};

4つ目に追加することができました。

項目を更新する

const animals = {a1:"",a2:"",a3:""};
animals["a2"] = "";

console.log(animals);//{a1:"犬",a2:"猿",a3:"鳥"};

既にあるキーの値を更新することができました。

項目を削除する

const animals = {a1:"",a2:"",a3:""};
delete animals["a2"];

console.log(animals);//{a1:"犬",a3:"鳥"};

配列と同様deleteを使って削除できます。

変更を禁止する

Object.freezeを使用すると連想配列の変更を禁止にできます。

const animals = Object.freeze({a1:"",a2:"",a3:""});

console.log(animals["a1"]); // 犬
console.log(animals["a2"]); // 猫
console.log(animals["a3"]); // 鳥

animals["a4"] = "";
console.log(animals["a4"]);// undefined
console.log(animals);//{a1:"犬",a2:"猫",a3:"鳥"}

Object.freezeを使用すると配列に追加させようとしてもできていないことがわかります。

数を取得する

Object.keys(obj)を使うことでできます。

const animals = {a1:"",a2:"",a3:""};
console.log(Object.keys(animals).length); // 3

Object.keysメソッドとlengthプロパティを使用して連想配列の数を取得しています。(キーと値あわせて1つと数えます。)

まとめ

少し難しいですがうまく使えると便利だと思うので少しでも役立ったら嬉しいです。

参考リンク

JavaScript 連想配列の仕組みと使い方のサンプル

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

How to Install React JS in Laravel 7 with Bootstrap

If you want to build a React application with Laravel, then the first and foremost thing is you must know how to install React in Laravel 7. We will use laravel/ui Package to install react in laravel with Bootstrap 4.

click here to read more:
https://www.positronx.io/how-to-install-react-js-in-laravel-with-bootstrap/

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSの webview で"ほぼ"絶対にSafariで開くnpm package "go-to-safari"

iOS 上の WebView から Safari で開く

iOS の Facebook や Messager アプリで開いたページのリンクを Safari で開くことができるようになる npm package を公開しました。

以下のようにすると iOS 上の WebView から Safari で指定した URL を開くことができます。

<a href="https://example.com/" class="outer-link">link</a>
<script src="https://unpkg.com/go-to-safari@1.0.2/lib/g2s.js"></script>
<script>
g2s(".outer-link"); // querySelector で対象のリンクを指定
</script>

sample

https://youheinakagawa.github.io/go-to-safari-js/

npm package

go-to-safari

参考にしたページ

How open link in safari mobile app from webview
https://stackoverflow.com/a/53028249

どういうときに使うのか?

Facebook アプリなどでページを開いたときにアプリの制限で使えない機能があります。
とくに AR Quick Look が使えないため、Facebook で AR Quick Look が埋め込まれているページを開いても AR の体験ができない現象が発生します。

Facebook という共有の場で体験ができないという機会損失をできる限り少なくするために開発しました。

Stack Overflow にあるように ftp:// スキーマーなら Safari が開くという仕組みになります。
自前で ftp サーバーを立てているため不安定な時もあるかもしれません。
npm の package を作ることも公開することも初めてなので至らぬ点が多々あるかと思います。
UA で判定しているため Safari と全く同一の UA で WebView を設定されていると開くことはできません。
それでもよろしければ是非お使いください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【GAS x Vue.js】JavaScript のみで今、家計簿をつくるとしたら【ハンズオン付き!】

「JavaScriptのみ」&「無料」&「サーバーレス」なスプレッドシートと連携した家計簿をつくる方法を考えてみました。
実際に家計簿アプリを作るハンズオン付きです!

なにを作ったの?

Web上でデータを登録すると、スプレッドシートに反映される家計簿アプリです。
実際のページはこちら。使い方は「家計簿アプリお試し方法」で説明します。

データ追加の他に、データ編集と

データ削除を行えます。

スプレッドシートは月ごとにシートで管理され、Webアプリと同じように収支の合計も確認できます。

また、カテゴリ別の支出も確認できます。

使用した技術

  • バックエンド
    • Google Apps Script (GAS)
  • フロントエンド
    • Vue.js / Vue Router / Vuex
    • Vuetify
    • axios

全体の構成はこんなイメージです。シンプル。

制作のポイント

GAS で REST API もどきを作った

GAS で受け付けることのできるリクエストは GETPOST の2種類だけです。(doGet, doPost 関数)
これでは REST API を作ることはできないので、
リクエスト内容にメソッドの文字列を入れることで擬似的に GET, POST, PUT, DELETE に対応させました!:v:

家計簿は月ごとにシートを分けた

:o: メリット

  • 指定年月のデータ取得時の実行コストが低くなる
  • データ数が増えても API が重くなりにくい
  • スプレッドシートの内容を確認しやすい

指定年月のシートのデータをすべて取得すればいいので、「データが指定年月のものであるか?」を確認する必要がなくなります。
そのため、データ数が多くなっても1枚のシートで管理するより重くなりにくいです。:muscle:

また、Webアプリ/スプレッドシートどちらからでも家計簿のデータを確認しやすいのが強みです。

:x: デメリット

  • データ年月の編集時の実行コストが高くなる
  • 月をまたいだデータの取得/集計などが困難になる

編集前後でデータの年月を変えると、
「編集前の年月シートから削除」→「編集後の年月シートに追加」
する必要があるので、コストが高くなってしまいます。(そんな編集をすることは滅多にないと思いますが…)

また、今回作った API の仕様だと、1年分のデータを取得するのに、12回 API を叩く必要があります。
Webアプリでは月ごとの表示しかしていませんが、より細かい集計などするには API の改修が必要そうです。:innocent:

家計簿アプリお試し方法

それでは、実際にこのアプリを試してみる方法を紹介します。
3ステップだけで完了します!:sparkles:

STEP 1:シート準備

Google スプレッドシートで新しいシートを作成して、「ツール」タブ→「スクリプトエディタ」をクリックします。

↓が表示されていることを確認します。

もし表示されていなかった場合は、「実行」タブから V8 ランタイムを有効にします。

コード.gsこのプログラムをコピペして保存します。プロジェクト名は好きな名前でOKです。

STEP 2:API URL の発行

「公開」タブ→「ウェブ アプリケーションとして導入」をクリックします。

「Project version」は「New」、
「Execute the app as」は「Me (自分のメールアドレス)」、
「Who has access to the app」は「Anyone, even anonymous」
で「Deploy」ボタンをクリックします。

「Authorization Required」というダイアログが表示されるので、
「許可を確認」ボタンをクリックしたあと、スプレッドシートを作成したアカウントでログインして「許可」ボタンをクリックします。

「Deploy as web app」というダイアログが表示されれば、準備完了です。
「Current web app URL」の内容をコピーしておきます。
※実際の URL はもっと長いです。

この URL は誰でもアクセスができてしまうので、一応 authToken を設定できます。(URL を他人に知られることはないと思いますが)

※この設定は任意です
「ファイル」タブ→「プロジェクトのプロパティ」→「スクリプトのプロパティ」を開きます。
authToken という行を追加して、UUID v4 などの値を設定します。

STEP 3:アプリ設定

家計簿アプリの設定を開き、「API URL」と「Auth Token」を入力して、「保存」ボタンをクリック。
※ STEP 2 で authToken を設定してない方は空のままでOKです。

右上にあるシートマークのボタンをクリックします。
← このマーク

エラーが表示されなければ準備完了です!
実際に家計簿データを入力して、スプレッドシートに反映されるか試してみてください!

アプリを作ってみる!

おまたせしました!ここからハンズオンになります!
対象は JavaScript / Vue.js 初心者~中級者向けです。

ハンズオンは以下の3部構成でお送りします!

内容結構長いので、記事の最後まで飛びたい方はこちらをクリック。

環境構築

開発環境

Node.jsYarn がインストールされている前提で進めます。
下記のバージョンと近いものか、高いものであれば基本動くと思います。

> node -v
v12.16.3

> yarn -v
v1.22.4

Vue CLI 4 のインストール

Vue.js アプリを簡単につくることができるようになる Vue CLI をインストールします。
執筆時点の最新バージョンは 4.4.5 でした。

> yarn global add @vue/cli

> vue --version
@vue/cli 4.4.5

プロジェクトの作成

vue create 好きなアプリ名 と打つと、プロジェクトを作成できます。
実行例では、アプリ名を gas-account-book として進めます。

> vue create gas-account-book

デフォルトを選択すると一発でプロジェクトを作成できますが、
今回 Vue RouterVuex を追加したいので、マニュアルで進めます。
(上下でカーソル移動、エンターで決定できます)

Vue CLI v4.4.5
? Please pick a preset:    
  default (babel, eslint)  
> Manually select features

「Babel」「Linter / Formatter」の選択はそのままで、
「Router」「Vuex」を追加して決定します。
(スペースキーで選択の状態を切り替えられます)

? Check the features needed for your project: 
>(*) Babel
 ( ) TypeScript
 ( ) Progressive Web App (PWA) Support        
 (*) Router
 (*) Vuex
 ( ) CSS Pre-processors
 (*) Linter / Formatter
 ( ) Unit Testing
 ( ) E2E Testing

history mode を使うか?と尋ねられますが、今回は使わないので「n」を入力します。

? Use history mode for router? (Requires proper server setup for index fallback in production) (Y/n) n

ESLint の設定はエラー防止のみの「ESLint with error prevention only」を選択します。

? Pick a linter / formatter config: 
> ESLint with error prevention only
  ESLint + Airbnb config
  ESLint + Standard config
  ESLint + Prettier

保存のときに Lint してもらいたいので、「Lint on save」のまま次へ。

? Pick additional lint features:
>(*) Lint on save
 ( ) Lint and fix on commit

設定ファイルは config ファイルに書いてほしいので、
「In dedicated config files」を選択。

? Where do you prefer placing config for Babel, ESLint, etc.? 
> In dedicated config files
  In package.json

今回のプロジェクト設定を保存するか聞かれますが、「N」で次へ。

? Save this as a preset for future projects? (y/N) N

必要パッケージのインストールがはじまります。

Vue CLI v4.4.5
✨  Creating project in /xxxxx/gas-account-book.
?️  Initializing git repository...
⚙️  Installing CLI plugins. This might take a while...

このように表示されれば完了です。

?  Successfully created project gas-account-book.
?  Get started with the following commands:

 $ cd gas-account-book
 $ yarn serve

gas-account-book ディレクトリ内に移動します。

> cd gas-account-book

次に Vuetify を追加します。
Vue CLI を使うと、簡単にプラグインもインストールできます!

ちなみに Vuetify とは Vue 用のマテリアルデザインフレームワークです。
今回はデザインを Vuetify まかせにしてサボります。

このハンズオンに出てくる v- から始まるタグはすべて Vuetify のコンポーネントです。
デザイン面の話はあまり触れないので、気になる方は公式ドキュメントを参照してください。

> vue add vuetify

この設定はデフォルトで進めます。

✔  Successfully installed plugin: vue-cli-plugin-vuetify

? Choose a preset:
> Default (recommended)
  Prototype (rapid development)
  Configure (advanced)

このように表示されれば完了です。

✔  Successfully invoked generator for plugin: vue-cli-plugin-vuetify
 vuetify  Discord community: https://community.vuetifyjs.com
 vuetify  Github: https://github.com/vuetifyjs/vuetify
 vuetify  Support Vuetify: https://github.com/sponsors/johnleider

yarn serve コマンドで開発サーバーを起動してみます。

> yarn serve

localhost:8080 ブラウザーでアクセスして、
「Welcome to Vuetify」が表示されれば環境構築完了です!:sparkles:

この開発サーバーでは ホットリロード が有効なので、ファイル編集がすぐに反映されます。
以降はこのサーバーが起動している前提で進めて行きます。

現時点のソースコード一覧はこちらから確認できます!

Vue.js / Vue Router / Vuex でフロント実装してみる

ようやく環境構築が終わりました。
はじめに、ディレクトリ構成について軽く把握しておきましょう。
ざっとこんな感じになっています。

src/
  assets/ ...... ロゴなどのアセット
  components/ .. 主に再利用する vue コンポーネント
  plugins/ ..... vuetify などのプラグイン
  router/ ...... ルーティングの設定
  store/ ....... Vuexストアの設定
  views/ ....... ページを構成する vue ファイル
  App.vue ...... Vueアプリのメインファイル
  main.js ...... エントリポイントとなるファイル

App.vue を書き換えてみる

さっそくですが、メインファイルである App.vue が自動生成された状態のままなので、
不要なものを消してシンプルにします。

App.vue
<template>
  <v-app>
    <!-- ツールバー -->
    <v-app-bar app color="green" dark>
      <!-- タイトル -->
      <v-toolbar-title>GAS 家計簿</v-toolbar-title>
      <v-spacer></v-spacer>
      <!-- テーブルアイコンのボタン -->
      <v-btn icon to="/">
        <v-icon>mdi-file-table-outline</v-icon>
      </v-btn>
      <!-- 歯車アイコンのボタン -->
      <v-btn icon to="/settings">
        <v-icon>mdi-cog</v-icon>
      </v-btn>
    </v-app-bar>
    <!-- メインコンテンツ -->
    <v-main>
      <v-container fluid>
        <!-- router-view の中身がパスによって切り替わる -->
        <router-view></router-view>
      </v-container>
    </v-main>
  </v-app>
</template>

<script>
export default {
  name: 'App'
}
</script>

上部に緑色のツールバーが表示されました。
toolbar-min.png

ツールバーに表示されたボタンを押すと画面が切り替わると思います。
これは、v-btnto 属性を設定すると、ボタンが押されたときにそのパスへ移動できるからです。

また、v-iconMaterial Design Icons が使えます。
使い方は mdi-アイコン名v-icon の中身に書くだけです。

App.vue|9-14行目
<!-- テーブルアイコンのボタン -->
<v-btn icon to="/"> <!-- クリックで "/" へ移動する -->
  <v-icon>mdi-file-table-outline</v-icon>
</v-btn>
<!-- 歯車アイコンのボタン -->
<v-btn icon to="/settings"> <!-- クリックで "/settings" へ移動する -->
  <v-icon>mdi-cog</v-icon>
</v-btn>

URL のパスによって、この router-view の中身が切り替わります。
/ は最初に表示されていた画面(Welcome to Vuetify)、
/settings はまだ作っていないので、何もない画面に切り替わります。

App.vue|20-21行目
<!-- router-view の中身がパスによって切り替わる -->
<router-view></router-view>

ルーティングの設定は src/router/index.js に書かれています。
このファイルを見てみましょう。

router/index.js|7-12行目
const routes = [
  {
    path: '/',      // パスが "/" のときの設定
    name: 'Home',   // このルートに "Home" という名前をつける
    component: Home // router-view の中に Home コンポーネントを表示する
  },

この Home コンポーネント は、3行目で読み込まれています。
/ では src/views/Home.vue を表示しているようですね!

router/index.js|3行目
import Home from '../views/Home.vue'

ここまでの大雑把な流れは、
App.vue -> router -> views
ということがわかりました!

ページの中身を書き換えてみる

では、ページを中身を書き換えてみます。
ついでに views ディレクトリの中に Settings.vue も作りましょう。
どちらも中身はシンプルにします。

Home.vue
<template>
  <div>
    <h1>Home コンポーネントだよ</h1>
  </div>
</template>

<script>
export default {
  name: 'Home'
}
</script>
Settings.vue
<template>
  <div>
    <h1>Settings コンポーネントだよ</h1>
  </div>
</template>

<script>
export default {
  name: 'Settings'
}
</script>

ルーティングの設定を変えて、HomeSettings が表示されるようにします。

router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import Settings from '../views/Settings.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/settings',
    name: 'Settings',
    component: Settings
  }
]

const router = new VueRouter({
  routes
})

export default router

このように表示が切り替われば大丈夫です。

ホームの画面だけ実装してみる

それでは、ホームの画面だけ実装していきましょう。
月選択フォーム、データ追加ボタン、検索フォーム、テーブル
の4つを作っていきます。

Home.vue
<template>
  <div>
    <v-card>
      <v-card-title>
        <!-- 月選択 -->
        <v-col cols="8">
          <v-menu 
            ref="menu"
            v-model="menu"
            :close-on-content-click="false"
            :return-value.sync="yearMonth"
            transition="scale-transition"
            offset-y
            max-width="290px"
            min-width="290px"
          >
            <template v-slot:activator="{ on }">
              <v-text-field
                v-model="yearMonth"
                prepend-icon="mdi-calendar"
                readonly
                v-on="on"
                hide-details
              />
            </template>
            <v-date-picker
              v-model="yearMonth"
              type="month"
              color="green"
              locale="ja-jp"
              no-title
              scrollable
            >
              <v-spacer/>
              <v-btn text color="grey" @click="menu = false">キャンセル</v-btn>
              <v-btn text color="primary" @click="$refs.menu.save(yearMonth)">選択</v-btn>
            </v-date-picker>
          </v-menu>
        </v-col>
        <v-spacer/>
        <!-- 追加ボタン -->
        <v-col class="text-right" cols="4">
          <v-btn dark color="green">
            <v-icon>mdi-plus</v-icon>
          </v-btn>
        </v-col>
        <!-- 検索フォーム -->
        <v-col cols="12">
          <v-text-field
            v-model="search"
            append-icon="mdi-magnify"
            label="Search"
            single-line
            hide-details
          />
        </v-col>
      </v-card-title>
      <!-- テーブル -->
      <v-data-table
        class="text-no-wrap"
        :headers="tableHeaders"
        :items="tableData"
        :search="search"
        :footer-props="footerProps"
        :loading="loading"
        :sort-by="'date'"
        :sort-desc="true"
        :items-per-page="30"
        mobile-breakpoint="0"
      >
      </v-data-table>
    </v-card>
  </div>
</template>

<script>
export default {
  name: 'Home',

  data () {
    const today = new Date()
    const year = today.getFullYear()
    const month = ('0' + (today.getMonth() + 1)).slice(-2)

    return {
      /** ローディング状態 */
      loading: false,
      /** 月選択メニューの状態 */
      menu: false,
      /** 検索文字 */
      search: '',
      /** 選択年月 */
      yearMonth: `${year}-${month}`,
      /** テーブルに表示させるデータ */
      tableData: [
        /** サンプルデータ */
        { id: 'a34109ed', date: '2020-06-01', title: '支出サンプル', category: '買い物', tags: 'タグ1', income: null, outgo: 2000, memo: 'メモ' },
        { id: '7c8fa764', date: '2020-06-02', title: '収入サンプル', category: '給料', tags:'タグ1,タグ2', income: 2000, outgo: null, memo: 'メモ' }
      ]
    }
  },

  computed: {
    /** テーブルのヘッダー設定 */
    tableHeaders () {
      return [
        { text: '日付', value: 'date', align: 'end' },
        { text: 'タイトル', value: 'title', sortable: false },
        { text: 'カテゴリ', value: 'category', sortable: false },
        { text: 'タグ', value: 'tags', sortable: false },
        { text: '収入', value: 'income', align: 'end' },
        { text: '支出', value: 'outgo', align: 'end' },
        { text: 'メモ', value: 'memo', sortable: false },
        { text: '操作', value: 'actions', sortable: false }
      ]
    },

    /** テーブルのフッター設定 */
    footerProps () {
      return { itemsPerPageText: '', itemsPerPageOptions: [] }
    }
  }
}
</script>

こんな感じになればOKです。

…いきなり長いコードになってしまいました。:bow:
重要だと思うところを説明します。

検索フォームでは v-model を使って入力されたデータを同期させています。
この場合は this.search で入力された内容を読み取ることができます。

Home.vue|47-56行目
<!-- 検索フォーム -->
<v-col cols="12">
  <v-text-field
    v-model="search"          入力したデータを this.search と同期
    append-icon="mdi-magnify" 検索アイコン
    label="Search"            ラベル名
    single-line               1行だけ入力できる
    hide-details              文字カウントなどを非表示
  />
</v-col>

テーブルにはさまざまなプロパティを設定できます。
今回設定したものはこんな感じです。

Home.vue|58-70行目
<!-- テーブル -->
<v-data-table
  class="text-no-wrap"        文字を折り返さないようにするクラス
  :headers="tableHeaders"     ヘッダー設定
  :items="tableData"          テーブルに表示するデータ
  :search="search"            検索する文字
  :footer-props="footerProps" フッター設定
  :loading="loading"          ローディング状態
  :sort-by="'date'"           ソート初期設定(列名)
  :sort-desc="true"           ソート初期設定(降順)
  :items-per-page="30"        テーブルに最大何件表示するか
  mobile-breakpoint="0"       モバイル表示にさせる画面サイズ(今回はモバイル表示にさせたくないので 0 を設定)
>

headers にヘッダーの設定、items に表示するデータを入れるという感じです。
ヘッダーの設定の中身をみてみます。

text には表示させる列名、 value には表示させるデータのキーを設定します。
たとえば、 { text: '日付', value: 'date' }
日付 列にはデータの date を表示する」という設定になります。
また、 align でテキストの寄せる方向、 sortable でソート可否を設定できます。

views/Home.vue|104-116行目
/** テーブルのヘッダー設定 */
tableHeaders () {
  return [
    { text: '日付', value: 'date', align: 'end' },
    { text: 'タイトル', value: 'title', sortable: false },
    { text: 'カテゴリ', value: 'category', sortable: false },
    { text: 'タグ', value: 'tags', sortable: false },
    { text: '収入', value: 'income', align: 'end' },
    { text: '支出', value: 'outgo', align: 'end' },
    { text: 'メモ', value: 'memo', sortable: false },
    { text: '操作', value: 'actions', sortable: false }
  ]
},

一応サンプルデータが表示されていますが、
日付やタグの表示、収支を3桁区切りにしたいですよね。
次にこれを実装します。

~ 省略 ~ の部分に変更はありません。

Home.vue
<!-- ~ 省略 ~ -->
<!-- テーブル -->
<v-data-table
   省略 
>
  <!-- 日付列 -->
  <template v-slot:item.date="{ item }">
    {{ parseInt(item.date.slice(-2)) + '' }}
  </template>
  <!-- タグ列 -->
  <template v-slot:item.tags="{ item }">
    <div v-if="item.tags">
      <v-chip
        class="mr-2"
        v-for="(tag, i) in item.tags.split(',')"
        :key="i"
      >
        {{ tag }}
      </v-chip>
    </div>
  </template>
  <!-- 収入列 -->
  <template v-slot:item.income="{ item }">
    {{ separate(item.income) }}
  </template>
  <!-- タグ列 -->
  <template v-slot:item.outgo="{ item }">
    {{ separate(item.outgo) }}
  </template>
  <!-- 操作列 -->
  <template v-slot:item.actions="{}">
    <v-icon class="mr-2">mdi-pencil</v-icon>
    <v-icon>mdi-delete</v-icon>
  </template>
</v-data-table>
<!-- ~ 省略 ~ -->
views/Home.vue
/** ~ 省略 ~ */
<script>
export default {
  name: 'Home',
  data () {
    /** ~ 省略 ~ */
  },
  computed: {
    /** ~ 省略 ~ */ 
  },
  methods: {
    /**
     * 数字を3桁区切りにして返します。
     * 受け取った数が null のときは null を返します。
     */
    separate (num) {
      return num !== null ? num.toString().replace(/(\d)(?=(\d{3})+$)/g, '$1,') : null
    }
  }
}
</script>

一気にそれっぽくなりました。

これは Vuetify の決まりごとになってしまいますが、
v-data-table 内の templatev-slot:item.列名="{ item }" とすると、その列のデータを加工できます。

<!-- 日付列 -->
<template v-slot:item.date="{ item }">
  <!-- この中で、日付は item.date でアクセスできる -->
  <!-- '2020-06-01' → '1日' に加工 -->
  {{ parseInt(item.date.slice(-2)) + '' }}
</template>

現時点のソースコード一覧はこちらから確認できます!

操作ダイアログを作る

データを追加/編集するダイアログを作ります。
新しく components ディレクトリの中に ItemDialog.vue を作成します。

ItemDialog.vue
<template>
  <!-- データ追加/編集ダイアログ -->
  <v-dialog
    v-model="show"
    scrollable
    persistent
    max-width="500px"
    eager
  >
    <v-card>
      <v-card-title>{{ titleText }}</v-card-title>
      <v-divider/>
      <v-card-text>
        <v-form ref="form" v-model="valid">
          <!-- 日付選択 -->
          <v-menu
            ref="menu"
            v-model="menu"
            :close-on-content-click="false"
            :return-value.sync="date"
            transition="scale-transition"
            offset-y
            max-width="290px"
            min-width="290px"
          >
            <template v-slot:activator="{ on }">
              <v-text-field
                v-model="date"
                prepend-icon="mdi-calendar"
                readonly
                v-on="on"
                hide-details
              />
            </template>
            <v-date-picker
              v-model="date"
              color="green"
              locale="ja-jp"
              :day-format="date => new Date(date).getDate()"
              no-title
              scrollable
            >
              <v-spacer/>
              <v-btn text color="grey" @click="menu = false">キャンセル</v-btn>
              <v-btn text color="primary" @click="$refs.menu.save(date)">選択</v-btn>
            </v-date-picker>
          </v-menu>
          <!-- タイトル -->
          <v-text-field
            label="タイトル"
            v-model.trim="title"
            :counter="20"
            :rules="titleRules"
          />
          <!-- 収支 -->
          <v-radio-group
            row
            v-model="inout"
            hide-details
            @change="onChangeInout"
          >
            <v-radio label="収入" value="income"/>
            <v-radio label="支出" value="outgo"/>
          </v-radio-group>
          <!-- カテゴリ -->
          <v-select
            label="カテゴリ"
            v-model="category"
            :items="categoryItems"
            hide-details
          />
          <!-- タグ -->
          <v-select
            label="タグ"
            v-model="tags"
            :items="tagItems"
            multiple
            chips
            :rules="[tagRule]"
          />
          <!-- 金額 -->
          <v-text-field
            label="金額"
            v-model.number="amount"
            prefix="¥"
            pattern="[0-9]*"
            :rules="amountRules"
          />
          <!-- メモ -->
          <v-text-field
            label="メモ"
            v-model="memo"
            :counter="50"
            :rules="[memoRule]"
          />
        </v-form>
      </v-card-text>
      <v-divider/>
      <v-card-actions>
        <v-spacer/>
        <v-btn
          color="grey darken-1"
          text
          :disabled="loading"
          @click="onClickClose"
        >
          キャンセル
        </v-btn>
        <v-btn
          color="blue darken-1"
          text
          :disabled="!valid"
          :loading="loading"
          @click="onClickAction"
        >
          {{ actionText }}
        </v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

<script>
export default {
  name: 'ItemDialog',

  data () {
    return {
      /** ダイアログの表示状態 */
      show: false,
      /** 入力したデータが有効かどうか */
      valid: false,
      /** 日付選択メニューの表示状態 */
      menu: false,
      /** ローディング状態 */
      loading: false,

      /** 操作タイプ 'add' or 'edit' */
      actionType: 'add',
      /** id */
      id: '',
      /** 日付 */
      date: '',
      /** タイトル */
      title: '',
      /** 収支 'income' or 'outgo' */
      inout: '',
      /** カテゴリ */
      category: '',
      /** タグ */
      tags: [],
      /** 金額 */
      amount: 0,
      /** メモ */
      memo: '',

      /** 収支カテゴリ一覧 */
      incomeItems: ['カテ1', 'カテ2'],
      outgoItems: ['カテ3', 'カテ4'],
      /** 選択カテゴリ一覧 */
      categoryItems: [],
      /** タグリスト */
      tagItems: ['タグ1', 'タグ2'],
      /** 編集前の年月(編集時に使う) */
      beforeYM: '',

      /** バリデーションルール */
      titleRules: [
        v => v.trim().length > 0 || 'タイトルは必須です',
        v => v.length <= 20 || '20文字以内で入力してください'
      ],
      tagRule: v => v.length <= 5 || 'タグは5種類以内で選択してください',
      amountRules: [
        v => v >= 0 || '金額は0以上で入力してください',
        v => Number.isInteger(v) || '整数で入力してください'
      ],
      memoRule: v => v.length <= 50 || 'メモは50文字以内で入力してください'
    }
  },

  computed: {
    /** ダイアログのタイトル */
    titleText () {
      return this.actionType === 'add' ? 'データ追加' : 'データ編集'
    },
    /** ダイアログのアクション */
    actionText () {
      return this.actionType === 'add' ? '追加' : '更新'
    }
  },

  methods: {
    /**
     * ダイアログを表示します。
     * このメソッドは親から呼び出されます。
     */
    open (actionType, item) {
      this.show = true
      this.actionType = actionType
      this.resetForm(item)

      if (actionType === 'edit') {
        this.beforeYM = item.date.slice(0, 7)
      }
    },
    /** キャンセルがクリックされたとき */
    onClickClose () {
      this.show = false
    },
    /** 追加/更新がクリックされたとき */
    onClickAction () {
      // あとで実装
    },
    /** 収支が切り替わったとき */
    onChangeInout () {
      if (this.inout === 'income') {
        this.categoryItems = this.incomeItems
      } else {
        this.categoryItems = this.outgoItems
      }
      this.category = this.categoryItems[0]
    },
    /** フォームの内容を初期化します */
    resetForm (item = {}) {
      const today = new Date()
      const year = today.getFullYear()
      const month = ('0' + (today.getMonth() + 1)).slice(-2)
      const date = ('0' + today.getDate()).slice(-2)

      this.id = item.id || ''
      this.date = item.date || `${year}-${month}-${date}`
      this.title = item.title || ''
      this.inout = item.income != null ? 'income' : 'outgo'

      if (this.inout === 'income') {
        this.categoryItems = this.incomeItems
        this.amount = item.income || 0
      } else {
        this.categoryItems = this.outgoItems
        this.amount = item.outgo || 0
      }

      this.category = item.category || this.categoryItems[0]
      this.tags = item.tags ? item.tags.split(',') : []
      this.memo = item.memo || ''

      this.$refs.form.resetValidation()
    }
  }
}
</script>

…重要だと思うところを説明します。

ホーム画面の検索フォームと同じように、v-text-field を使っています。
rules を設定するだけで、いい感じにバリデーションしてくれます。

ItemDialog.vue|48-54行目
<!-- タイトル -->
<v-text-field
  label="タイトル"
  v-model.trim="title"
  :counter="20"
  :rules="titleRules"
/>
バリデーションルールの書き方
// v には現在入力されているデータが入ってる
v => /** OKにする条件 */ || /** NGのときに表示させる文字 */

ルールはこのように複数設定できます。

ItemDialog.vue|168-171行目
titleRules: [
  v => v.trim().length > 0 || 'タイトルは必須です',
  v => v.length <= 20 || '20文字以内で入力してください'
],

現状のままだとダイアログの動作確認できないので、
ホーム画面でダイアログを表示できるように ItemDialog.vue をインポートします。

Home.vue
<template>
  <div>
    <v-card>
      <v-card-title>
        <!-- ~ 省略 ~ -->
        <!-- 追加ボタン -->
        <v-col class="text-right" cols="4">
          <v-btn dark color="green" @click="onClickAdd">
            <v-icon>mdi-plus</v-icon>
          </v-btn>
        </v-col>
        <!-- ~ 省略 ~ -->
      </v-card-title>
      <!-- テーブル -->
      <v-data-table>
        <!-- ~ 省略 ~ -->
        <!-- 操作列 -->
        <template v-slot:item.actions="{ item }">
          <v-icon class="mr-2" @click="onClickEdit(item)">mdi-pencil</v-icon>
          <v-icon>mdi-delete</v-icon>
        </template>
      </v-data-table>
    </v-card>
    <!-- 追加/編集ダイアログ -->
    <ItemDialog ref="itemDialog"/>
  </div>
</template>

<script>
import ItemDialog from '../components/ItemDialog.vue'

export default {
  name: 'Home',
  components: {
    ItemDialog
  },

  /** ~ 省略 ~ */

  methods: {
    /** ~ 省略 ~ */
    /** 追加ボタンがクリックされたとき */
    onClickAdd () {
      this.$refs.itemDialog.open('add')
    },
    /** 編集ボタンがクリックされたとき */
    onClickEdit (item) {
      this.$refs.itemDialog.open('edit', item)
    }
  }
}
</script>

テーブル右上に表示されている追加ボタン、
操作列の編集ボタンをクリックして、動作を確認してみます。

追加ボタンをクリックしたときは何も入力されていないフォーム、
編集ボタンをクリックしたときは初期値が入力されているフォームが表示されればOKです。

バリデーションも実行されるか確認してみます。
問題なく動いてそうです。

コンポーネントの子要素には ref 属性をつけると this.$refs.名前 でアクセスできます。

<!-- 追加/編集ダイアログ -->
<ItemDialog ref="itemDialog"/>

今回はダイアログに itemDialog という名前をつけたので、 this.$refs.itemDialog ですね。

追加ボタンをクリックしたとき、追加/編集ダイアログの open を実行することで
ダイアログの表示を行うようにしています。

/** 追加ボタンがクリックされたとき */
onClickAdd () {
  this.$refs.itemDialog.open('add')
},

追加/編集ダイアログと同じように削除ダイアログも作成します。
新しく components ディレクトリの中に DeleteDialog.vue を作成します。
コードは少なめです:smile:

DeleteDialog.vue
<template>
  <!-- 削除ダイアログ -->
  <v-dialog
    v-model="show"
    persistent
    max-width="290"
  >
    <v-card>
      <v-card-title/>
      <v-card-text class="black--text">
        「{{ item.title }}」を削除しますか?
      </v-card-text>
      <v-card-actions>
        <v-spacer/>
        <v-btn color="grey" text :disabled="loading" @click="onClickClose">キャンセル</v-btn>
        <v-btn color="red" text :loading="loading" @click="onClickDelete">削除</v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

<script>
export default {
  name: 'DeleteDialog',

  data () {
    return {
      /** ダイアログの表示状態 */
      show: false,
      /** ローディング状態 */
      loading: false,
      /** 受け取ったデータ */
      item: {}
    }
  },

  methods: {
    /**
     * ダイアログを表示します。
     * このメソッドは親から呼び出されます。
     */    
    open (item) {
      this.show = true
      this.item = item
    },
    /** キャンセルがクリックされたとき */
    onClickClose () {
      this.show = false
    },
    /** 削除がクリックされたとき */
    onClickDelete () {
      // あとで実装
    }
  }
}
</script>

追加/編集ダイアログと同じように、ホームで表示させます。

Home.vue
    <!-- ~ 省略 ~ -->
    </v-card>
    <!-- 追加/編集ダイアログ -->
    <ItemDialog ref="itemDialog"/>
    <!-- 削除ダイアログ -->
    <DeleteDialog ref="deleteDialog"/>
  </div>
</template>

<script>
import ItemDialog from '../components/ItemDialog.vue'
import DeleteDialog from '../components/DeleteDialog.vue'

export default {
  name: 'Home',

  components: {
    ItemDialog,
    DeleteDialog
  },

  /** ~ 省略 ~ */

  methods: {
    /** ~ 省略 ~ */
    /** 削除ボタンがクリックされたとき */
    onClickDelete (item) {
      this.$refs.deleteDialog.open(item)
    }
  }
}
</script>

削除ボタンをクリックして、ダイアログが表示されればOkです。

現時点のソースコード一覧はこちらから確認できます!

設定の画面だけ作る

次に、手をつけていなかった設定画面を作ります。

Settings.vue
<template>
  <div class="form-wrapper">
    <p>※設定はこのデバイスのみに保存されます。</p>
    <v-form v-model="valid">
      <h3>アプリ設定</h3>
      <!-- アプリ名 -->
      <v-text-field
        label="アプリ名"
        v-model="settings.appName"
        :counter="30"
        :rules="[appNameRule]"
      />
      <!-- API URL -->
      <v-text-field
        label="API URL"
        v-model="settings.apiUrl"
        :counter="150"
        :rules="[stringRule]"
      />
      <!-- Auth Token -->
      <v-text-field
        label="Auth Token"
        v-model="settings.authToken"
        :counter="150"
        :rules="[stringRule]"
      />
      <h3>カテゴリ/タグ設定</h3>
      <p>カンマ( &#44; )区切りで入力してください。</p>
      <!-- 収入カテゴリ -->
      <v-text-field
        label="収入カテゴリ"
        v-model="settings.strIncomeItems"
        :counter="150"
        :rules="[stringRule, ...categoryRules]"
      />
      <!-- 支出カテゴリ -->
      <v-text-field
        label="支出カテゴリ"
        v-model="settings.strOutgoItems"
        :counter="150"
        :rules="[stringRule, ...categoryRules]"
      />
      <!-- タグ -->
      <v-text-field
        label="タグ"
        v-model="settings.strTagItems"
        :counter="150"
        :rules="[stringRule, tagRule]"
      />
      <v-row class="mt-4">
        <v-spacer/>
        <v-btn color="primary" :disabled="!valid" @click="onClickSave">保存</v-btn>
      </v-row>
    </v-form>
  </div>
</template>

<script>
export default {
  name: 'Settings',

  data () {
    const createItems = v => v.split(',').map(v => v.trim()).filter(v => v.length !== 0)
    const itemMaxLength = v => createItems(v).reduce((a, c) => Math.max(a, c.length), 0)

    return {
      /** 入力したデータが有効かどうか */
      valid: false,
      /** 設定 */
      settings: {
        appName: 'GAS 家計簿',
        apiUrl: '',
        authToken: '',
        strIncomeItems: '給料, ボーナス, 繰越',
        strOutgoItems: '食費, 趣味, 交通費, 買い物, 交際費, 生活費, 住宅, 通信, 車, 税金',
        strTagItems: '固定費, カード'
      },

      /** バリデーションルール */
      appNameRule: v => v.length <= 30 || '30文字以内で入力してください',
      stringRule: v => v.length <= 150 || '150文字以内で入力してください',
      categoryRules: [
        v => createItems(v).length !== 0 || 'カテゴリは1つ以上必要です',
        v => itemMaxLength(v) <= 4 || '各カテゴリは4文字以内で入力してください'
      ],
      tagRule: v => itemMaxLength(v) <= 4 || '各タグは4文字以内で入力してください'
    }
  },

  methods: {
    onClickSave () {
      // あとで実装
    }
  }
}
</script>

<style>
.form-wrapper {
  max-width: 500px;
  margin: auto;
}
</style>

追加/編集ダイアログと同じようにフォームを表示させ、バリデーションさせています。

スプレッド構文を使うと、いい感じにバリデーションルールを使い回せます。

const rules = ['rule2', 'rule3']
console.log(['rule1', ...rules]) // -> ['rule1', 'rule2', 'rule3']
Settings.vue|29-35行目
<!-- 収入カテゴリ -->
<v-text-field
  label="収入カテゴリ"
  v-model="settings.strIncomeItems"
  :counter="150"
  :rules="[stringRule, ...categoryRules]"
/>

設定を保存/読み込みできるようにする

設定画面で保存ボタンを押しても入力したデータは保存されていません。
また、この状態だとホーム画面で設定を読み込むこともできません。

ここで登場するのが Vuex です。状態(State)を管理できます。
公式ドキュメントにある画像がわかりやすかったので引用します。

とても大雑把に説明すると、
「画面から Actions を使って状態更新」→「State から状態読み込み」という流れになります。

今回は「設定」「家計簿データ」の状態管理に Vuex を使用します。
さっそく、設定を保存/読み込みできるよう src/store/index.js を書き換えます。
設定の内容は永続的に保存したいので、localStorage を利用します。

store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

/** 
 * State
 * Vuexの状態
 */
const state = {
  /** 設定 */
  settings: {
    appName: 'GAS 家計簿',
    apiUrl: '',
    authToken: '',
    strIncomeItems: '給料, ボーナス, 繰越',
    strOutgoItems: '食費, 趣味, 交通費, 買い物, 交際費, 生活費, 住宅, 通信, 車, 税金',
    strTagItems: '固定費, カード'
  }
}

/**
 * Mutations
 * ActionsからStateを更新するときに呼ばれます
 */
const mutations = {
  /** 設定を保存します */
  saveSettings (state, { settings }) {
    state.settings = { ...settings }
    document.title = state.settings.appName

    localStorage.setItem('settings', JSON.stringify(settings))
  },

  /** 設定を読み込みます */
  loadSettings (state) {
    const settings = JSON.parse(localStorage.getItem('settings'))
    if (settings) {
      state.settings = Object.assign(state.settings, settings)
    }
    document.title = state.settings.appName
  }
}

/**
 * Actions
 * 画面から呼ばれ、Mutationをコミットします
 */
const actions = {
  /** 設定を保存します */
  saveSettings ({ commit }, { settings }) {
    commit('saveSettings', { settings })
  },

  /** 設定を読み込みます */
  loadSettings ({ commit }) {
    commit('loadSettings')
  }
}

/** カンマ区切りの文字をトリミングして配列にします */
const createItems = v => v.split(',').map(v => v.trim()).filter(v => v.length !== 0)

/**
 * Getters
 * 画面から取得され、Stateを加工して渡します
 */
const getters = {
  /** 収入カテゴリ(配列) */
  incomeItems (state) {
    return createItems(state.settings.strIncomeItems)
  },
  /** 支出カテゴリ(配列) */
  outgoItems (state) {
    return createItems(state.settings.strOutgoItems)
  },
  /** タグ(配列) */
  tagItems (state) {
    return createItems(state.settings.strTagItems)
  }
}

const store = new Vuex.Store({
  state,
  mutations,
  actions,
  getters
})

export default store

突然 Mutations, Getters が現れました。
こちらも公式ドキュメント画像の引用になりますが、
Vuex では「Actions」→「Mutations」→「State」という流れで状態を更新します。

State は Mutations からしか変更しないようにします

Getters はコメントにもありますが、State を加工して渡します。
Vuex 版 computed のようなものです。

次に、設定画面で Vuex を使って設定保存できるようにします。

Settings.vue
<script>
export default {
  name: 'Settings',

  data () {
    /** ~ 省略 ~ */

    return {
      /** ~ 省略 ~ */

      /** 設定 */
      settings: { ...this.$store.state.settings },

      /** ~ 省略 ~ */
    }
  },

  methods: {
    /** 保存ボタンがクリックされたとき */
    onClickSave () {
      this.$store.dispatch('saveSettings', { settings: this.settings })
    }
  }
}
</script>

各コンポーネントでストアには $store でアクセスでき、
ストアから stategetters にアクセスできます。

// Stateのsettingsにアクセス
this.$store.state.settings

フォームの内容を書き換えるのと同時に State も書き換わるは困るので、
一度 settings の内容をコピーして使用するようにしています。

/** 設定 */
settings: { ...this.$store.state.settings }

Actionsdispatch メソッドで実行できます。

// dispatch('Action名', ペイロード)
this.$store.dispatch('saveSettings', { settings: this.settings })

// 以下の形式でもOKです
this.$store.dispatch(
  type: 'saveSettings',
  settings: this.settings
)

最後に、アプリ起動時に localStorage から読み込む処理を追加します。
ついでにアプリ名を反映させます。

App.vue
<template>
  <v-app>
    <!-- ツールバー -->
    <v-app-bar app color="green" dark>
      <!-- タイトル -->
      <v-toolbar-title>{{ appName }}</v-toolbar-title>
      <!-- ~ 省略 ~ -->
    </v-app-bar>
    <!-- ~ 省略 ~ -->
  </v-app>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'App',

  computed: mapState({
    appName: state => state.settings.appName
  }),

  // Appインスタンス生成前に一度だけ実行されます
  beforeCreate () {
    this.$store.dispatch('loadSettings')
  }
}
</script>

beforeCreate の中で loadSettings を呼び出すようにしました。

mapState を使うと、State のアクセスを簡潔にできます。
色々な書き方があるのでこちらも参考にしてみてください。

// mapState を使わないと…
this.$store.state.settings.appName // 長い

// mapState を使うと…
this.appName // 短い

現時点のソースコード一覧はこちらから確認できます!

家計簿アプリの動作を実装してみる

それでは、フロント実装最後の仕上げに入っていきます!:sparkles:
家計簿データを追加/編集/削除できるようにします。

Vuex ストア実装

家計簿のデータは State に保存します。
データは月ごとに管理したいので、以下のような構造で持つようにします。

// 家計簿データ(abData)の構造
{
  '2020-06': [
    { id: 'xxx', title: 'xxx',  },
    { id: 'yyy', title: 'yyy',  },
  ],
  '2020-07': [
    { id: 'zzz', title: 'zzz',  }
  ],
  
}

それでは、家計簿データの Action, Mutation を実装します。

store/index.js
/** ~ 省略 ~ */

/** 
 * State
 * Vuexの状態
 */
const state = {
  /** 家計簿データ */
  abData: {},

  /** ~ 省略 ~ */
}

/**
 * Mutations
 * ActionsからStateを更新するときに呼ばれます
 */
const mutations = {
  /** 指定年月の家計簿データをセットします */
  setAbData (state, { yearMonth, list }) {
    state.abData[yearMonth] = list
  },

  /** データを追加します */
  addAbData (state, { item }) {
    const yearMonth = item.date.slice(0, 7)
    const list = state.abData[yearMonth]
    if (list) {
      list.push(item)
    }
  },

  /** 指定年月のデータを更新します */
  updateAbData (state, { yearMonth, item }) {
    const list = state.abData[yearMonth]
    if (list) {
      const index = list.findIndex(v => v.id === item.id)
      list.splice(index, 1, item)
    }
  },

  /** 指定年月&IDのデータを削除します */
  deleteAbData (state, { yearMonth, id }) {
    const list = state.abData[yearMonth]
    if (list) {
      const index = list.findIndex(v => v.id === id)
      list.splice(index, 1)
    }
  },

  /** ~ 省略 ~ */
}

/**
 * Actions
 * 画面から呼ばれ、Mutationをコミットします
 */
const actions = {
  /** 指定年月の家計簿データを取得します */
  fetchAbData ({ commit }, { yearMonth }) {
    // サンプルデータを初期値として入れる
    const list = [
      { id: 'a34109ed', date: `${yearMonth}-01`, title: '支出サンプル', category: '買い物', tags: 'タグ1', income: null, outgo: 2000, memo: 'メモ' },
      { id: '7c8fa764', date: `${yearMonth}-02`, title: '収入サンプル', category: '給料', tags:'タグ1,タグ2', income: 2000, outgo: null, memo: 'メモ' }
    ]
    commit('setAbData', { yearMonth, list })
  },

  /** データを追加します */
  addAbData ({ commit }, { item }) {
    commit('addAbData', { item })
  },

  /** データを更新します */
  updateAbData ({ commit }, { beforeYM, item }) {
    const yearMonth = item.date.slice(0, 7)
    if (yearMonth === beforeYM) {
      commit('updateAbData', { yearMonth, item })
      return
    }
    const id = item.id
    commit('deleteAbData', { yearMonth: beforeYM, id })
    commit('addAbData', { item })
  },

  /** データを削除します */
  deleteAbData ({ commit }, { item }) {
    const yearMonth = item.date.slice(0, 7)
    const id = item.id
    commit('deleteAbData', { yearMonth, id })
  },

  /** ~ 省略 ~ */
}
/** ~ 省略 ~ */

家計簿データを取得/追加/更新/削除する処理を追加しました。
どの処理も API 完成後に通信させます。

今回の実装内容は家計簿データの操作なので、複雑な処理はありませんが、
更新だけ少し特殊なので補足します。

// (Actions)
/** データを更新します */
updateAbData ({ commit }, { beforeYM, item }) {
  const yearMonth = item.date.slice(0, 7)
  // 更新前後で年月の変更が無ければそのまま値を更新
  if (yearMonth === beforeYM) {
    commit('updateAbData', { yearMonth, item })
    return
  }
  // 更新があれば、更新前年月のデータから削除して、新しくデータ追加する
  const id = item.id
  commit('deleteAbData', { yearMonth: beforeYM, id })
  commit('addAbData', { item })
},

ホーム画面からストアを呼び出す

Home.vue
<template>
  <div>
    <v-card>
      <v-card-title>
        <!-- 月選択 -->
        <v-col cols="8">
          <v-menu 
             省略 
          >
            <!-- ~ 省略 ~ -->
            <v-date-picker
               省略 
            >
              <v-spacer/>
              <v-btn text color="grey" @click="menu = false">キャンセル</v-btn>
              <v-btn text color="primary" @click="onSelectMonth">選択</v-btn>
            </v-date-picker>
          </v-menu>
        </v-col>
        <!-- ~ 省略 ~ -->
        </v-col>
      </v-card-title>
      <!-- ~ 省略 ~ -->
    </v-card>
    <!-- ~ 省略 ~ -->
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex'

/** ~ 省略 ~ */

export default {
  /** ~ 省略 ~ */

  data () {
    /** ~ 省略 ~ */

    return {
      /** ~ 省略 ~ */

      /** テーブルに表示させるデータ */
      tableData: []
    }
  },

  computed: {
    ...mapState({
      /** 家計簿データ */
      abData: state => state.abData
    }),

    /** ~ 省略 ~ */
  },

  methods: {
    ...mapActions([
      /** 家計簿データを取得 */
      'fetchAbData'
    ]),

    /** 表示させるデータを更新します */
    updateTable () {
      const yearMonth = this.yearMonth
      const list = this.abData[yearMonth]

      if (list) {
        this.tableData = list
      } else {
        this.fetchAbData({ yearMonth })
        this.tableData = this.abData[yearMonth]
      }
    },

    /** 月選択ボタンがクリックされたとき */
    onSelectMonth () {
      this.$refs.menu.save(this.yearMonth)
      this.updateTable()
    },

    /** ~ 省略 ~ */
  },

  created () {
    this.updateTable()
  }
}
</script>

mapState は App.vue で利用しましたが、
それ以外にも mapActions, mapGetters などが用意されています。
スプレッド構文を使うといい感じに利用できます。

methods: {
  ...mapActions([
    /** 家計簿データを取得 */
    /**
     * this.$store.dispatch('fetchAbData') を
     * this.fetchAbData として使えるようにする
     */
    'fetchAbData'
  ]),
  
}

追加/編集ダイアログからストアを呼び出す

収支カテゴリ設定などを State から取得するのと、
フォームに入力されたデータで追加/更新できるようにします。

ItemDialog.vue
<script>
import { mapActions, mapGetters } from 'vuex'

export default {
  name: 'ItemDialog',

  data () {
    return {
      /** ~ 省略 ~ */
      /** メモ */
      memo: '',

      /** 選択可能カテゴリ一覧 */
      categoryItems: [],
      /** 編集前の年月(編集時に使う) */
      beforeYM: '',

      /** ~ 省略 ~ */
    }
  },

  computed: {
    ...mapGetters([
      /** 収支カテゴリ */
      'incomeItems',
      'outgoItems',
      /** タグ */
      'tagItems'
    ]),

    /** ~ 省略 ~ */
  },

  methods: {
    ...mapActions([
      /** データ追加 */
      'addAbData',
      /** データ更新 */
      'updateAbData'
    ]),

    /** ~ 省略 ~ */

    /** 追加/更新がクリックされたとき */
    onClickAction () {
      const item = {
        date: this.date,
        title: this.title,
        category: this.category,
        tags: this.tags.join(','),
        memo: this.memo,
        income: null,
        outgo: null
      }
      item[this.inout] = this.amount || 0

      if (this.actionType === 'add') {
        item.id = Math.random().toString(36).slice(-8) // ランダムな8文字のIDを生成
        this.addAbData({ item })
      } else {
        item.id = this.id
        this.updateAbData({ beforeYM: this.beforeYM, item })
      }

      this.show = false
    },
    /** ~ 省略 ~ */
  }
}
</script>

ダイアログからデータの追加/編集ができるか確認してみてください!

削除ダイアログからストアを呼び出す

DeleteDialog.vue
<script>
import { mapActions } from 'vuex'

export default {
  name: 'DeleteDialog',

  /** ~ 省略 ~ */

  methods: {
    ...mapActions([
      /** データ削除 */
      'deleteAbData'
    ]),

    /** ~ 省略 ~ */

    /** 削除がクリックされたとき */
    onClickDelete () {
      this.deleteAbData({ item: this.item })
      this.show = false
    }
  }
}
</script>

ダイアログからデータの削除ができるか確認してみてください!

「Vue.js / Vue Router / Vuex でフロント実装してみる」は以上になります。
お疲れ様でした!:tada: :beer:

現時点のソースコード一覧はこちらから確認できます!

Google Apps Script で REST API もどきを作ってみる

こちらから GAS で API の作成になります!!

「こだわりポイント」でも触れましたが、擬似的にメソッドを指定して
GET で取得、POST で追加、PUT で更新、DELETE で削除できる API を作成します。

シート準備

まずはじめにシートの準備をします。
Google スプレッドシートで新しいシートを作成して、「ツール」タブ→「スクリプトエディター」をクリックします。

↓が表示されていることを確認します。

もし表示されていなかった場合は、「実行」タブから V8 ランタイムを有効にしてください。

プロジェクトの名前を「家計簿API」と保存して、コード.gsapi.gs にリネームします。
api.gs の内容を書き換えます。

api.gs
const ss = SpreadsheetApp.getActive()

function test () {
  console.log(ss.getName())
}

メニューで「test」が選択されていることを確認してから
:arrow_forward: ボタンをクリックします。

「Authorization Required」というダイアログが表示されるので、
「許可を確認」ボタンをクリックしたあと、スプレッドシートを作成したアカウントでログインして「許可」ボタンをクリックします。

Ctrl + Enter (mac は Command + Enter) でログを確認できます。
作成したシートの名前が表示されればOKです。

家計簿のテンプレートをつくる

まずはじめに、家計簿のテンプレートとなるシートを作成する関数 insertTemplate を作ります。
シートのイメージを大雑把にまとめると

A1:B4 に収支確認エリア

A6:H6 にテーブルのヘッダー

J1:L1 にカテゴリ別支出のヘッダー

です。これをプログラムに落とし込みます。

api.gs
const ss = SpreadsheetApp.getActive()

function test () {
  insertTemplate('2020-06')
}

/**
 * 指定年月のテンプレートシートを作成します
 * @param {String} yearMonth
 * @returns {Sheet} sheet
 */
function insertTemplate (yearMonth) {
  const { SOLID_MEDIUM, DOUBLE } = SpreadsheetApp.BorderStyle

  const sheet = ss.insertSheet(yearMonth, 0)
  const [year, month] = yearMonth.split('-')

  // 収支確認エリア
  sheet.getRange('A1:B1')
    .merge()
    .setValue(`${year}${parseInt(month)}月`)
    .setFontWeight('bold')
    .setHorizontalAlignment('center')
    .setBorder(null, null, true, null, null, null, 'black', SOLID_MEDIUM)

  sheet.getRange('A2:A4')
    .setValues([['収入:'], ['支出:'], ['収支差:']])
    .setFontWeight('bold')
    .setHorizontalAlignment('right')

  sheet.getRange('B2:B4')
    .setFormulas([['=SUM(F7:F)'], ['=SUM(G7:G)'], ['=B2-B3']])
    .setNumberFormat('#,##0')

  sheet.getRange('A4:B4')
    .setBorder(true, null, null, null, null, null, 'black', DOUBLE)

  // テーブルヘッダー
  sheet.getRange('A6:H6')
    .setValues([['id', '日付', 'タイトル', 'カテゴリ', 'タグ', '収入', '支出', 'メモ']])
    .setFontWeight('bold')
    .setBorder(null, null, true, null, null, null, 'black', SOLID_MEDIUM)

  sheet.getRange('F7:G')
    .setNumberFormat('#,##0')

  // カテゴリ別支出
  sheet.getRange('J1')
    .setFormula('=QUERY(B7:H, "select D, sum(G), sum(G) / "&B3&"  where G > 0 group by D order by sum(G) desc label D \'カテゴリ\', sum(G) \'支出\'")')

  sheet.getRange('J1:L1')
    .setFontWeight('bold')
    .setBorder(null, null, true, null, null, null, 'black', SOLID_MEDIUM)

  sheet.getRange('L1')
    .setFontColor('white')

  sheet.getRange('K2:K')
    .setNumberFormat('#,##0')

  sheet.getRange('L2:L')
    .setNumberFormat('0.0%')

  sheet.setColumnWidth(9, 21)

  return sheet
}

スプレッドシートは SpreadsheetApp を利用して取得します。
取得の方法は2つあります。

  • スプレッドシートIDを指定する openById(id)
  • 紐付いているスプレッドシートを取得する getActive()

今回はスプレッドシートと紐付いている GAS プロジェクトを作成したので、後者で取得します。

const ss = SpreadsheetApp.getActive()

新規シートを作成するときには insertSheet メソッドを使います。
引数にシート名とインデックスを指定します。インデックスは 0 で一番左に追加されます。
返り値は新規作成したシートです。

const sheet = ss.insertSheet('シート名', インデックス)

セル操作の流れは、範囲(Range)を取得してから各操作を実行します。
シートの getRange メソッドで範囲を取得できます。
A1 形式のほうが(個人的に)見やすいので、今回のプログラムではこちらに統一します。

ex.
/** 単一のセルを取得する */
// getRange(行, 列)
sheet.getRange(1, 2) // B1
// getRange(A1形式)
sheet.getRange('B1') // B1

/** 複数のセルを取得する */
// getRange(開始行, 開始列, 何行分選択するか, 何列分選択するか)
sheet.getRange(1, 2, 3, 4) // B1:E3
// getRange(A1形式)
sheet.getRange('B1:E3')    // B1:E3

各セル操作は Range を返すので、メソッドチェーンを利用できます。
可能な操作はすべて公式リファレンスに記載されているので、こちらも確認してみてください。

ex.メソッドチェーン
sheet.getRange('A1')
  .func1() // どの操作も
  .func2() // A1に対して
  .func3() // 実行される

セル操作については重要な setValue, setValues メソッドを説明します。
単一セルの値をセットするときは setValue
複数セルの値をセットするときは setValues を使います。

setValues では必ず2次元配列を渡します。改行してみると分かりやすいです。

ex.
// A1に"A1 value"をセット
sheet.getRange('A1')
  .setValue('A1 value')

// 複数セルの値をセットするときは
// 2次元配列を渡します
sheet.getRange('A1:B2')
  .setValues([
    ['A1', 'B1'],
    ['A2', 'B2']
  ])

// 1行(1列)だけでも2次元配列を渡します
sheet.getRange('A6:H6')
  .setValues([
    ['id', '日付', 'タイトル', 'カテゴリ', 'タグ', '収入', '支出', 'メモ']
  ])

また、= から始まる数式をセットしたい場合は、
setFormula, setFormulas メソッドを使います。

ex.
sheet.getRange('A1')
  .setFormula('=PI()')

sheet.getRange('B2:B4')
  .setFormulas([
    ['=SUM(F7:F)'],
    ['=SUM(G7:G)'],
    ['=B2-B3']
  ])

この状態で test を実行してみます。
2020-06 というシートが新しく作成され、テンプレートが書き込まれることを確認してください!

データを追加する onPost をつくる

それでは API のプログラム作成に入ります!
API は成功時には何かしらの結果を返し、エラー時には { error: 'メッセージ' } を返す仕様にします。

まずはデータの追加です。onPost と、
一応入力データのバリデーションを行う isValid を作成します。

api.gs
const ss = SpreadsheetApp.getActive()

function test () {
  onPost({
    item: {
      date: '2020-07-01',
      title: '支出サンプル',
      category: '食費',
      tags: 'タグ1,タグ2',
      income: null,
      outgo: 3000,
      memo: 'メモメモ'  
    }
  })
}

/** --- API --- */

/**
 * データを追加します
 * @param {Object} params
 * @param {Object} params.item 家計簿データ
 * @returns {Object} 追加した家計簿データ
 */
function onPost ({ item }) {
  if (!isValid(item)) {
    return {
      error: '正しい形式で入力してください'
    }
  }
  const { date, title, category, tags, income, outgo, memo } = item

  const yearMonth = date.slice(0, 7)
  const sheet = ss.getSheetByName(yearMonth) || insertTemplate(yearMonth)

  const id = Utilities.getUuid().slice(0, 8)
  const row = ["'" + id, "'" + date, "'" + title, "'" + category, "'" + tags, income, outgo, "'" + memo]
  sheet.appendRow(row)

  return { id, date, title, category, tags, income, outgo, memo }
}

/** --- common --- */

/**
 * 指定年月のテンプレートシートを作成します
 * @param {String} yearMonth
 * @returns {Sheet} sheet
 */
function insertTemplate (yearMonth) {
  /** ~ 省略 ~ */
}

/**
 * データが正しい形式か検証します
 * @param {Object} item
 * @returns {Boolean} isValid
 */
function isValid (item = {}) {
  const strKeys = ['date', 'title', 'category', 'tags', 'memo']
  const keys = [...strKeys, 'income', 'outgo']

  // すべてのキーが存在するか
  for (const key of keys) {
    if (item[key] === undefined) return false
  }

  // 収支以外が文字列であるか
  for (const key of strKeys) {
    if (typeof item[key] !== 'string') return false
  }

  // 日付が正しい形式であるか
  const dateReg = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/
  if (!dateReg.test(item.date)) return false

  // 収支のどちらかが入力されているか
  const { income: i, outgo: o } = item
  if ((i === null && o === null) || (i !== null && o !== null)) return false

  // 入力された収支が数字であるか
  if (i !== null && typeof i !== 'number') return false
  if (o !== null && typeof o !== 'number') return false

  return true
}

シートの取得は getSheetByName でシート名を指定して取得します。
シートがなかった場合は null が返ってくるので、insertTemplate が実行されます。

// 指定年月シートを取得する、なかったらテンプレートシートを作成する
const sheet = ss.getSheetByName(yearMonth) || insertTemplate(yearMonth)

また、シートには appendRow というシンプルで便利なメソッドが用意されているので、
引数に配列を渡すだけで簡単にデータの追加をできます。

収支以外は文字列として扱ってほしいので、値の前にシングルクォートを付与してからシートに追加します。
値をセットするとき、文字列を渡しても数字や日付などは自動で変換されるので注意が必要です。

ex.
const a1 = sheet.getRange('A1').setValue("100").getValue()
const b1 = sheet.getRange('B1').setValue("'100").getValue()

console.log(typeof a1) // -> "number"
console.log(typeof b1) // -> "string"

ID は UtilitiesgetUuid を利用して UUID の先頭8文字だけ切り取るという謎のプログラムで生成しています。
公式リファレンスで使える便利メソッドが記載されているので、ぜひ確認してみてください。

const id = Utilities.getUuid().slice(0, 8)

この状態で test を実行してみます。
シートが新しく作成され、データの追加を確認してください!

データ取得する onGet をつくる

追加ができたら、次は取得してみたいですね。onGet を作ります。

api.gs
const ss = SpreadsheetApp.getActive()

function test () {
  const result = onGet({ yearMonth: '2020-07' })
  console.log(result)
}

/** --- API --- */

/**
 * 指定年月のデータ一覧を取得します
 * @param {Object} params
 * @param {String} params.yearMonth 年月
 * @returns {Object[]} 家計簿データ
 */
function onGet ({ yearMonth }) {
  const ymReg = /^[0-9]{4}-(0[1-9]|1[0-2])$/

  if (!ymReg.test(yearMonth)) {
    return {
      error: '正しい形式で入力してください'
    }
  }

  const sheet = ss.getSheetByName(yearMonth)
  const lastRow = sheet ? sheet.getLastRow() : 0

  if (lastRow < 7) {
    return []
  }

  const list = sheet.getRange('A7:H' + lastRow).getValues().map(row => {
    const [id, date, title, category, tags, income, outgo, memo] = row
    return {
      id,
      date,
      title,
      category,
      tags,
      income: (income === '') ? null : income,
      outgo: (outgo === '') ? null : outgo,
      memo
    }
  })

  return list
}

/** ~ 省略 ~ */

テーブルのヘッダーが A6:H6 にあるので、A7:H{最終行} のデータを取得します。

シートの最終行は getLastRow で取得できます。
指定年月のシートが存在しない場合も考慮して、最終行が7未満の場合は空の配列を返します。

const sheet = ss.getSheetByName(yearMonth)
const lastRow = sheet ? sheet.getLastRow() : 0

if (lastRow < 7) {
  return []
}

データを返すときはオブジェクトにして返したいので、
getValues で受け取った2次元配列を map でオブジェクトに加工します。

空白セルは空文字('')として取得されるので、収支だけ注意が必要です。

ex.
const values = [
  ['xxx', '2020-07-01', 'sample1'],
  ['yyy', '2020-07-02', 'sample2']
]

const list = values.map(row => {
  return {
    id: row[0],
    date: row[1],
    title: row[2]
  }
})

console.log(list)
// -> [
//      { id: "xxx", date: "2020-07-01", title: "sample1" },
//      { id: "yyy", date: "2020-07-02", title: "sample2" }
//    ]

この状態で test を実行してみます。
追加したデータがオブジェクトの配列で返ってくることを確認してください!

データ削除する onDelete をつくる

あと機能はあと2つです! onDelete を作ります。

api.gs
const ss = SpreadsheetApp.getActive()

function test () {
  const result = onDelete({ yearMonth: '2020-07', id: 'xxxxxxxx' })
  console.log(result)
}

/** --- API --- */

function onGet ({ yearMonth }) {
  /** ~ 省略 ~ */
}

function onPost ({ item }) {
  /** ~ 省略 ~ */
}

/**
 * 指定年月&idのデータを削除します
 * @param {Object} params
 * @param {String} params.yearMonth 年月
 * @param {String} params.id id
 * @returns {Object} メッセージ
 */
function onDelete ({ yearMonth, id }) {
  const ymReg = /^[0-9]{4}-(0[1-9]|1[0-2])$/
  const sheet = ss.getSheetByName(yearMonth)

  if (!ymReg.test(yearMonth) || sheet === null) {
    return {
      error: '指定のシートは存在しません'
    }
  }

  const lastRow = sheet.getLastRow()
  const index = sheet.getRange('A7:A' + lastRow).getValues().flat().findIndex(v => v === id)

  if (index === -1) {
    return {
      error: '指定のデータは存在しません'
    }
  }

  sheet.deleteRow(index + 7)
  return {
    message: '削除完了しました'
  }
}

/** ~ 省略 ~ */

内容はシンプルです。指定年月&id のデータが存在したら deleteRow で行を削除するだけです。
A7:A{最終行} で範囲の値を取得すると、2次元配列になっているのでフラットにしてから id を探します。

ex.
const values = [['xxx'], ['yyy'], ['zzz']]
const flatted = values.flat()
console.log(flatted) // -> ['xxx', 'yyy', 'zzz']
console.log(flatted.findIndex(v => v === 'yyy')) // -> 1

インデックスが見つかれば、インデックスに7行分足した行を削除するだけです。

sheet.deleteRow(index + 7)

この状態で test の指定年月&id を書き換えて実行してみます。
指定のデータが削除され、「削除完了しました」というメッセージをログで確認してください!

データ更新する onPut をつくる

最後の機能です! onPut を作ります。

api.gs
const ss = SpreadsheetApp.getActive()

function test () {
  onPut({
    beforeYM: '2020-07',
    item: {
      id: 'xxxxxxxx',
      date: '2020-07-31',
      title: '更新サンプル',
      category: '食費',
      tags: 'タグ1,タグ2',
      income: null,
      outgo: 5000,
      memo: '更新したよ'  
    }
  })
}

/** --- API --- */

function onGet ({ yearMonth }) {
  /** ~ 省略 ~ */
}

function onPost ({ item }) {
  /** ~ 省略 ~ */
}

function onDelete ({ yearMonth, id }) {
  /** ~ 省略 ~ */
}

/**
 * 指定データを更新します
 * @param {Object} params
 * @param {String} params.beforeYM 更新前の年月
 * @param {Object} params.item 家計簿データ
 * @returns {Object} 更新後の家計簿データ
 */
function onPut ({ beforeYM, item }) {
  const ymReg = /^[0-9]{4}-(0[1-9]|1[0-2])$/
  if (!ymReg.test(beforeYM) || !isValid(item)) {
    return {
      error: '正しい形式で入力してください'
    }
  }

  // 更新前と後で年月が違う場合、データ削除と追加を実行
  const yearMonth = item.date.slice(0, 7)
  if (beforeYM !== yearMonth) {
    onDelete({ yearMonth: beforeYM, id: item.id })
    return onPost({ item })
  }

  const sheet = ss.getSheetByName(yearMonth)
  if (sheet === null) {
    return {
      error: '指定のシートは存在しません'
    }
  }

  const id = item.id
  const lastRow = sheet.getLastRow()
  const index = sheet.getRange('A7:A' + lastRow).getValues().flat().findIndex(v => v === id)

  if (index === -1) {
    return {
      error: '指定のデータは存在しません'
    }
  }

  const row = index + 7
  const { date, title, category, tags, income, outgo, memo } = item

  const values = [["'" + date, "'" + title, "'" + category, "'" + tags, income, outgo, "'" + memo]]
  sheet.getRange(`B${row}:H${row}`).setValues(values)

  return { id, date, title, category, tags, income, outgo, memo }
}

/** ~ 省略 ~ */

編集だけ「更新前と後で年月が違う場合」を考慮しないといけません。
削除と追加の処理は onDeleteonPost に任せます。

// 更新前と後で年月が違う場合、データ削除と追加を実行
const yearMonth = item.date.slice(0, 7)
if (beforeYM !== yearMonth) {
  onDelete({ yearMonth: beforeYM, id: item.id })
  return onPost({ item })
}

同じシートで完結できる場合は id 列以外の B?:H?setValues で更新します。
編集する行はデータ削除の時と同じように探します。

const values = [["'" + date, "'" + title, "'" + category, "'" + tags, income, outgo, "'" + memo]]
sheet.getRange(`B${row}:H${row}`).setValues(values)

この状態で test の編集前年月と item の id を書き換えて実行してみます。
id 列以外のデータが更新されることを確認してください!

リクエストを受け取れるようにする

機能がすべて揃ったので、GAS 側でリクエストを受け取れるようにします。
GAS では doGet, doPost という関数を作ると、GET, POST を受け取ることができます。

この画像3回目の登場になりますが、doPost で受け取り、
onGet, onPost, onPut, onDelete に振り分ける処理を追加します。

const ss = SpreadsheetApp.getActive()
const authToken = PropertiesService.getScriptProperties().getProperty('authToken') || ''

/**
 * レスポンスを作成して返します
 * @param {*} content
 * @returns {TextOutput}
 */
function response (content) {
  const res = ContentService.createTextOutput()
  res.setMimeType(ContentService.MimeType.JSON)
  res.setContent(JSON.stringify(content))
  return res
}

/**
 * アプリにPOSTリクエストが送信されたとき実行されます
 * @param {Event} e
 * @returns {TextOutput}
 */
function doPost (e) {
  let contents
  try {
    contents = JSON.parse(e.postData.contents)
  } catch (e) {
    return response({ error: 'JSONの形式が正しくありません' })
  }

  if (contents.authToken !== authToken) {
    return response({ error: '認証に失敗しました' })
  }

  const { method = '', params = {} } = contents

  let result
  try {
    switch (method) {
      case 'POST':
        result = onPost(params)
        break
      case 'GET':
        result = onGet(params)
        break
      case 'PUT':
        result = onPut(params)
        break
      case 'DELETE':
        result = onDelete(params)
        break
      default:
        result = { error: 'methodを指定してください' }
    }
  } catch (e) {
    result = { error: e }
  }

  return response(result)
}

/** --- API --- */

/** ~ 省略 ~ */

GAS でレスポンスを返すときは ContentService を利用します。
作成した API では JSON しか返さないので mime type には MimeType.JSON を指定します。

function response (content) {
  const res = ContentService.createTextOutput()
  // レスポンスの Content-Type ヘッダーに "application/json" を設定する
  res.setMimeType(ContentService.MimeType.JSON)
  // オブジェクトを文字列にしてからレスポンスに詰め込む
  res.setContent(JSON.stringify(content))
  return res
}

次に doPost の中をみていきます。
送られたリクエストは e.postData.contents で取得できます。
文字列なので JSON にパースします。一応 try catch で囲んでおきます。

let contents
try {
  contents = JSON.parse(e.postData.contents)
} catch (e) {
  return response({ error: 'JSONの形式が正しくありません' })
}

受け取るリクエストの内容はこのような形式としてます。

リクエストの構造
{
  method: 'GET or POST or PUT or DELETE',
  authToken: '認証情報',
  params: {
    // 任意の処理の引数となるデータ
  }
}

誰でもアクセス可能な URL を発行するので、認証情報 authToken を持っている人しかアクセスできないようにします。
認証情報はソースコードに書きたくないので、PropertiesService を利用してスクリプトのプロパティから取得します。

「ファイル」タブ→「プロジェクトのプロパティ」→「スクリプトのプロパティ」から設定できます。

const authToken = PropertiesService.getScriptProperties().getProperty('authToken') || ''

処理はシンプルに case 文で分けます。
実行中にエラー起きても大丈夫なように、一応 try catch で囲んでおきます。

let result
try {
  switch (method) {
    case 'POST':
      result = onPost(params)
      break
    case 'GET':
      result = onGet(params)
      break
    case 'PUT':
      result = onPut(params)
      break
    case 'DELETE':
      result = onDelete(params)
      break
    default:
      result = { error: 'methodを指定してください' }
  }
} catch (e) {
  result = { error: e }
}

最後に実行結果をレスポンスとして返します。

return response(result)

ついに API 完成です!! :sparkles: :sparkles:

API を叩いてみる

API URL を発行します。

「公開」タブ→「ウェブ アプリケーションとして導入」をクリックします。

「Project version」は「New」、
「Execute the app as」は「Me (自分のメールアドレス)」、
「Who has access to the app」は「Anyone, even anonymous」
で「Deploy」ボタンをクリックします。

「Deploy as web app」というダイアログが表示されれば、準備完了です。
「Current web app URL」の内容をコピーしておきます。
※実際の URL はもっと長いです。

curl などを使ってこの API を叩いてみます。
authToken や yearMonth の値は置き換えてください。

> curl -L -d "{\"method\":\"GET\",\"authToken\":\"\",\"params\":{\"yearMonth\":\"2020-07\"}}" https://script.google.com/macros/s/xxxxx/exec

[{"id":"5e30de41","date":"2020-07-31","title":"サンプル","category":"食費","tags":"タグ1,タグ2","income":null,"outgo":5000,"memo":"メモメモ"}]

データが正常に返ってくればOKです!

「Google Apps Script で REST API もどきを作ってみる」は以上になります。
お疲れ様でした!:tada: :beer:

現時点のソースコード一覧はこちらから確認できます!

作った API と axios で実際に通信してみる

それではフロントと API を連携させて、家計簿を完成させていきます!

まずは、axios というライブラリをプロジェクトに追加します。
API にアクセスする際よく利用されます。

> yarn add axios

Vuex の中で axios を使って API にアクセスします。
この図の Actions <---> Backend API の部分を実装します。

API クライアントをつくる

src の中に新しく api ディレクトリを作成し、
その中に gasApi.js を作成します。

このリクエストを送れるようにします。

リクエストの構造
{
  method: 'GET or POST or PUT or DELETE',
  authToken: '認証情報',
  params: {
    // 任意の処理の引数となるデータ
  }
}
gasApi.js
import axios from 'axios'

// 共通のヘッダーを設定したaxiosのインスタンス作成
const gasApi = axios.create({
  headers: { 'content-type': 'application/x-www-form-urlencoded' }
})

// response共通処理
// errorが含まれていたらrejectする
gasApi.interceptors.response.use(res => {
  if (res.data.error) {
    return Promise.reject(res.data.error)
  }
  return Promise.resolve(res)
}, err => {
  return Promise.reject(err)
})

/**
 * APIのURLを設定します
 * @param {String} url
 */
const setUrl = url => {
  gasApi.defaults.baseURL = url
}

/**
 * authTokenを設定します
 * @param {String} token
 */
let authToken = ''
const setAuthToken = token => {
  authToken = token
}

/**
 * 指定年月のデータを取得します
 * @param {String} yearMonth
 * @returns {Promise}
 */
const fetch = yearMonth => {
  return gasApi.post('', {
    method: 'GET',
    authToken,
    params: {
      yearMonth
    }
  })
}

/**
 * データを追加します
 * @param {Object} item
 * @returns {Promise}
 */
const add = item => {
  return gasApi.post('', {
    method: 'POST',
    authToken,
    params: {
      item
    }
  })
}

/**
 * 指定年月&idのデータを削除します
 * @param {String} yearMonth
 * @param {String} id
 * @returns {Promise}
 */
const $delete = (yearMonth, id) => {
  return gasApi.post('', {
    method: 'DELETE',
    authToken,
    params: {
      yearMonth,
      id
    }
  })
}

/**
 * データを更新します
 * @param {String} beforeYM
 * @param {Object} item
 * @returns {Promise}
 */
const update = (beforeYM, item) => {
  return gasApi.post('', {
    method: 'PUT',
    authToken,
    params: {
      beforeYM,
      item
    }
  })
}

export default {
  setUrl,
  setAuthToken,
  fetch,
  add,
  delete: $delete,
  update
}

最初に共通の設定をしたインスタンスを作成します。あとからデフォルト設定を上書きもできます。

// 共通のヘッダーを設定したaxiosのインスタンス作成
const gasApi = axios.create({
  headers: { 'content-type': 'application/x-www-form-urlencoded' }
})

// リクエスト先のURLを変更する
gasApi.defaults.baseURL = 'https://xxxxx.com'

インスタンスを作成すると get, post, put, delete などのメソッドが使えます。
このメソッドで各リクエストを送信できます。今回は API の仕様上すべて post を使います。

gasApi.post(url, data)

また、interceptors を利用するとリクエスト時、レスポンス時の共通処理を設定できます。
今回はレスポンスの内容に error が含まれていた場合、reject してエラーにします。

// response共通処理
// errorが含まれていたらrejectする
gasApi.interceptors.response.use(res => {
  if (res.data.error) {
    return Promise.reject(res.data.error)
  }
  return Promise.resolve(res)
}, err => {
  return Promise.reject(err)
})

API からデータを取得する

それでは、作成した API クライアントを使用して実際に通信してみます。

store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import gasApi from '../api/gasApi'

Vue.use(Vuex)

/** 
 * State
 * Vuexの状態
 */
const state = {
  /** 家計簿データ */
  abData: {},

  /** ローディング状態 */
  loading: {
    fetch: false,
    add: false,
    update: false,
    delete: false
  },

  /** エラーメッセージ */
  errorMessage: '',

  /** 設定 */
  settings: {
    /** ~ 省略 ~ */
  }
}

/**
 * Mutations
 * ActionsからStateを更新するときに呼ばれます
 */
const mutations = {
  /** ~ 省略 ~ */

  /** ローディング状態をセットします */
  setLoading (state, { type, v }) {
    state.loading[type] = v
  },

  /** エラーメッセージをセットします */
  setErrorMessage (state, { message }) {
    state.errorMessage = message
  },

  /** 設定を保存します */
  saveSettings (state, { settings }) {
    state.settings = { ...settings }
    const { appName, apiUrl, authToken } = state.settings
    document.title = appName
    gasApi.setUrl(apiUrl)
    gasApi.setAuthToken(authToken)
    // 家計簿データを初期化
    state.abData = {}

    localStorage.setItem('settings', JSON.stringify(settings))
  },

  /** 設定を読み込みます */
  loadSettings (state) {
    const settings = JSON.parse(localStorage.getItem('settings'))
    if (settings) {
      state.settings = Object.assign(state.settings, settings)
    }
    const { appName, apiUrl, authToken } = state.settings
    document.title = appName
    gasApi.setUrl(apiUrl)
    gasApi.setAuthToken(authToken)
  }
}

/**
 * Actions
 * 画面から呼ばれ、Mutationをコミットします
 */
const actions = {
  /** 指定年月の家計簿データを取得します */
  async fetchAbData ({ commit }, { yearMonth }) {
    const type = 'fetch'
    commit('setLoading', { type, v: true })
    try {
      const res = await gasApi.fetch(yearMonth)
      commit('setAbData', { yearMonth, list: res.data })
    } catch (e) {
      commit('setErrorMessage', { message: e })
      commit('setAbData', { yearMonth, list: [] })
    } finally {
      commit('setLoading', { type, v: false })
    }
  },
  /** ~ 省略 ~ */
}

/** ~ 省略 ~ */

import で作成したクライアントを使えるようにして、
state にローディング状態とエラーメッセージを追加します。

import gasApi from '../api/gasApi'
/** ローディング状態 */
loading: {
  fetch: false,
  add: false,
  update: false,
  delete: false
},

/** エラーメッセージ */
errorMessage: '',

saveSettings, loadSettings 内でアプリ設定の apiUrl, authToken を gasApi に反映させます。

const { appName, apiUrl, authToken } = state.settings
document.title = appName
gasApi.setUrl(apiUrl)
gasApi.setAuthToken(authToken)

Actions の中でクライアントを使ってリクエストを送信します。

/** 指定年月の家計簿データを取得します */
async fetchAbData ({ commit }, { yearMonth }) {
  const type = 'fetch'
  // 取得の前にローディングをtrueにする
  commit('setLoading', { type, v: true })
  try {
    // APIにリクエスト送信
    const res = await gasApi.fetch(yearMonth)
    // 取得できたらabDataにセットする
    commit('setAbData', { yearMonth, list: res.data })
  } catch (e) {
    // エラーが起きたらメッセージをセット
    commit('setErrorMessage', { message: e })
    // 空の配列をabDataにセット
    commit('setAbData', { yearMonth, list: [] })
  } finally {
    // 最後に成功/失敗関係なくローディングをfalseにする
    commit('setLoading', { type, v: false })
  }
}

ホーム画面で fetchAdData を呼んでいた箇所も変更が必要なので、対応させます。

Home.vue
export default {
  name: 'Home',

  /** ~ 省略 ~ */

  data () {
    const today = new Date()
    const year = today.getFullYear()
    const month = ('0' + (today.getMonth() + 1)).slice(-2)

    return {
      /** 月選択メニューの状態 */
      menu: false,
      /** 検索文字 */
      search: '',
      /** 選択年月 */
      yearMonth: `${year}-${month}`,
      /** テーブルに表示させるデータ */
      tableData: []
    }
  },

  computed: {
    ...mapState({
      /** 家計簿データ */
      abData: state => state.abData,
      /** ローディング状態 */
      loading: state => state.loading.fetch,
    }),

    /** ~ 省略 ~ */
  },

  methods: {
    /** ~ 省略 ~ */

    /** 表示させるデータを更新します */
    async updateTable () {
      const yearMonth = this.yearMonth
      const list = this.abData[yearMonth]

      if (list) {
        this.tableData = list
      } else {
        await this.fetchAbData({ yearMonth })
        this.tableData = this.abData[yearMonth]
      }
    },

    /** ~ 省略 ~ */
  }
}

data の中で持っていた loading は消して、State の loading を使うようにします。

computed: {
  ...mapState({
    /** 家計簿データ */
    abData: state => state.abData,
    /** ローディング状態 */
    loading: state => state.loading.fetch,
  }),

  /** ~ 省略 ~ */
},

fetchAbDataPromise を返すようにしたので async/await に直します。

async updateTable () {
  /** ~ 省略 ~ */
  await this.fetchAbData({ yearMonth })
  /** ~ 省略 ~ */
},

このままだと通信でエラーが起きたときにメッセージが表示されないので、
App.vue にエラーメッセージを表示させるようにします。

App.vue
<template>
  <v-app>
    <!-- ~ 省略 ~ -->
    <v-main>
      <!-- ~ 省略 ~ -->
    </v-main>
    <!-- スナックバー -->
    <v-snackbar v-model="snackbar" color="error">{{ errorMessage }}</v-snackbar>
  </v-app>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'App',

  data () {
    return {
      snackbar: false
    }
  },

  computed: mapState({
    appName: state => state.settings.appName,
    errorMessage: state => state.errorMessage
  }),

  watch: {
    errorMessage () {
      this.snackbar = true
    }
  },

  /** ~ 省略 ~ */
}
</script>

スナックバーは画面下に表示される、通知のようなものです

watcherrorMessage を監視して、変更のあったタイミングでスナックバーを表示させます。
スナックバーは一定時間経過すると自動で消えます。

watch: {
  // errorMessageに変更があったら
  errorMessage () {
    // スナックバーを表示
    this.snackbar = true
  }
},

API との疎通確認をしてみます!

家計簿アプリの設定を開き、「API URL」と「Auth Token」を入力して、「保存」ボタンをクリック。
authToken を設定してない方は空のままでOKです。

ホーム画面に戻ってスプレッドシートのデータが表示されるか確認してみてください!

API で追加/更新できるようにする

次に、ItemDialog から API を使って追加/更新できるようにします。
さきほどと同じように Actions との内容を書き換えます。

store/index.js
/** ~ 省略 ~ */
const actions = {
  /** 指定年月の家計簿データを取得します */
  async fetchAbData ({ commit }, { yearMonth }) {
    /** ~ 省略 ~ */
  },

  /** データを追加します */
  async addAbData ({ commit }, { item }) {
    const type = 'add'
    commit('setLoading', { type, v: true })
    try {
      const res = await gasApi.add(item)
      commit('addAbData', { item: res.data })
    } catch (e) {
      commit('setErrorMessage', { message: e })
    } finally {
      commit('setLoading', { type, v: false })
    }
  },

  /** データを更新します */
  async updateAbData ({ commit }, { beforeYM, item }) {
    const type = 'update'
    const yearMonth = item.date.slice(0, 7)
    commit('setLoading', { type, v: true })
    try {
      const res = await gasApi.update(beforeYM, item)
      if (yearMonth === beforeYM) {
        commit('updateAbData', { yearMonth, item })
        return
      }
      const id = item.id
      commit('deleteAbData', { yearMonth: beforeYM, id })
      commit('addAbData', { item: res.data })
    } catch (e) {
      commit('setErrorMessage', { message: e })
    } finally {
      commit('setLoading', { type, v: false })
    }
  },
  /** ~ 省略 ~ */
}
/** ~ 省略 ~ */

ItemDialogasync/await に対応させます。

ItemDialog.vue
/** ~ 省略 ~ */
import { mapActions, mapGetters, mapState } from 'vuex'

export default {
  name: 'ItemDialog',

  data () {
    return {
      /** ダイアログの表示状態 */
      show: false,
      /** 入力したデータが有効かどうか */
      valid: false,
      /** 日付選択メニューの表示状態 */
      menu: false,

      /** 操作タイプ 'add' or 'edit' */
      actionType: 'add',
      /** ~ 省略 ~ */
    }
  },

  computed: {
    /** ~ 省略 ~ */

    ...mapState({
      /** ローディング状態 */
      loading: state => state.loading.add || state.loading.update
    }),

    /** ~ 省略 ~ */
  },

  methods: {
    /** ~ 省略 ~ */

    /** 追加/更新がクリックされたとき */
    async onClickAction () {
      const item = {
        date: this.date,
        title: this.title,
        category: this.category,
        tags: this.tags.join(','),
        memo: this.memo,
        income: null,
        outgo: null
      }
      item[this.inout] = this.amount || 0

      if (this.actionType === 'add') {
        await this.addAbData({ item })
      } else {
        item.id = this.id
        await this.updateAbData({ beforeYM: this.beforeYM, item })
      }

      this.show = false
    },
    /** ~ 省略 ~ */
  }
}

追加も編集も同じコンポーネントで行っているので、
どちらかが実行中であれば loading が true となるようにします。

...mapState({
  /** ローディング状態 */
  loading: state => state.loading.add || state.loading.update
}),

追加/編集がダイアログから実行できるか確認してみます!
どちらも実行できればOKです!スプレッドシートも確認してみてください。

API で削除できるようにする

最後に、DeleteDialog から API を使って削除できるようにします。

store/index.js
/** ~ 省略 ~ */
const actions = {
  /** 指定年月の家計簿データを取得します */
  async fetchAbData ({ commit }, { yearMonth }) {
    /** ~ 省略 ~ */
  },

  /** データを追加します */
  async addAbData ({ commit }, { item }) {
    /** ~ 省略 ~ */
  },

  /** データを更新します */
  async updateAbData ({ commit }, { beforeYM, item }) {
    /** ~ 省略 ~ */
  },

  /** データを削除します */
  async deleteAbData ({ commit }, { item }) {
    const type = 'delete'
    const yearMonth = item.date.slice(0, 7)
    const id = item.id
    try {
      await gasApi.delete(yearMonth, id)
      commit('deleteAbData', { yearMonth, id })
    } catch (e) {
      commit('setErrorMessage', { message: e })
    } finally {
      commit('setLoading', { type, v: false })
    }
  },
  /** ~ 省略 ~ */
}
/** ~ 省略 ~ */
DeleteDialog.vue
/** ~ 省略 ~ */
import { mapActions, mapState } from 'vuex'

export default {
  name: 'DeleteDialog',

  data () {
    return {
      /** ダイアログの表示状態 */
      show: false,
      /** 受け取ったデータ */
      item: {}
    }
  },

  computed: mapState({
    /** ローディング状態 */
    loading: state => state.loading.delete
  }),

  methods: {
    /** ~ 省略 ~ */

    /** 削除がクリックされたとき */
    async onClickDelete () {
      await this.deleteAbData({ item: this.item })
      this.show = false
    }
  }
}

削除がダイアログから実行できるか確認してみます!
実行できればOKです!スプレッドシートも確認してみてください。

ハンズオンは以上になります。お疲れ様でした!:tada: :beer:

ホーム画面で収支の総計を確認できるようにしたり、毎月1日に先月の収入を自動で繰り越す GAS プログラムを追加したり…。

フロントに限らず、GAS 側も自分好みにしてみてください!

ハンズオン完成時点のソースコード一覧はこちらから確認できます!

さいごに

Vue.js の勉強用に作成したものなので、
改善できるところなどありましたらコメントで教えていただけると嬉しいです!

ハンズオンを最後まで進めていただいた方、上から飛んできた方も
最後まで閲覧いただきありがとうございました!:bow:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【初学者向け】Vue CLIを使ってTODOアプリを作る

はじめに

作成するアプリについて

本記事ではVue CLIを使って、簡単なTODOアプリを作成していきます。
機能としては以下の通りです。

  • TODO一覧の表示
  • TODO追加
  • TODO削除

Untitled.gif

対象読者
- progateのhtml, javascriptコースを完了した方
- Vue.jsを勉強し始めた方

参考文献

Vue.jsでTodoアプリを作ってみよう
kenpapa (著)

前提条件

以下がインストール済みであることを前提とします。

  • Visual Studio Code
  • node.js
  • npm

目次

  • Vue CLIのインストール
  • プロジェクト作成
  • サンプルアプリの起動
  • TODO追加機能実装

    • 入力フォームの作成
    • TODOを格納する配列を定義
    • クリックイベントの実装
    • 追加処理の実装
    • TODOを一覧表示
    • チェック処理の実装
  • TODO削除機能実装

    • クリックイベントの実装
    • 削除処理の実装

Vue CLIのインストール

Vue CLIとは、Vue.jsアプリケーションの雛形を簡単に作成できるツールです。
コマンドベースでサンプルアプリを作成できます。

では、Vue CLIをインストールしていきましょう。
Macであればターミナル、Windowsであればコマンドプロンプトを起動し、以下のコマンドを実行してください。

npm install -g @vue/cli

プロジェクトの作成

Vue CLIのインストールが完了したら、プロジェクトを作成するディレクトリに移動してください。
今回はデスクトップ直下に"todo-app"というプロジェクト名で作成します。
ターミナルで以下コマンドを実行してください。

Desktop $ vue create todo-app

少し待つとプリセットの選択を求められます。
今回は追加で機能を設定できるManually select featuresを選択します。

Desktop $ vue create todo-app

Vue CLI v4.4.4
? Please pick a preset: 
  default (babel, eslint) 
❯ Manually select features 

デフォルトでBabelとLinter / Formatterにチェックが付いているかと思いますが、追加でRouterを選択します。
選択するにはカーソルを合わせてspaceキーを押下してください(Macの場合)
チェックがついたらEnterを押下してください。

? Please pick a preset: Manually select features
? Check the features needed for your project: 
 ◉ Babel
 ◯ TypeScript
 ◯ Progressive Web App (PWA) Support
❯◉ Router
 ◯ Vuex
 ◯ CSS Pre-processors
 ◉ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing

以降もいくつか選択を求められますが、全てデフォルトで大丈夫です。
全て選択するとプロジェクトの作成が始まります。

スクリーンショット 2020-07-04 18.02.11.png

プロジェクトの作成が完了すると以下の画面になります。
スクリーンショット 2020-07-04 18.03.45.png

サンプルアプリの起動

それでは動作確認のためアプリを起動してみましょう。
cdコマンドでプロジェクトルートディレクトリに移動します。

Desktop $ cd todo-app
todo-app $ 

起動コマンドを叩きます。

todo-app $ npm run serve

起動コマンドを実行すると、ビルドが開始されます。
スクリーンショット 2020-07-04 18.36.59.png

以下の画面が表示されれば、アプリの起動は完了です。
スクリーンショット 2020-07-04 18.37.15.png

では、実際にアクセスしてみましょう。
任意のブラウザでhttp://localhost:8080/にアクセスしてください。
以下の画面が表示されるかと思います。
スクリーンショット 2020-07-04 20.19.54.png

これで動作確認は完了です。
アプリの起動を停止しましょう。
ctrl + cで停止させてください。
スクリーンショット 2020-07-04 20.29.28.png

TODOアプリの確認

では、TODOアプリを作成していきましょう。
作成したプロジェクトをVisual Studio Codeで開いてください。
スクリーンショット 2020-07-04 18.16.52.png

App.vueを以下のように修正して、保存してください。

App.vue
<template>
  <div>
    <h3>My TODO</h3>
    <input v-model="newTodo" placeholder="Input here...">
    <button v-on:click="addTodo()">ADD</button>
    <h5>ToDo List</h5>
    <ul>
      <li v-for="(todo, i) in todos" v-bind:key="i">
        {{ todo }}
        <button v-on:click="deleteTodo(i)">DEL</button>
      </li>
    </ul>    
  </div>
</template>

<script>
export default {
  data() {
    return {
      todos: [],
      newTodo: ""
    }
  },
  methods: {
    addTodo() {
      if (this.newTodo === "") return;
      this.todos.push(this.newTodo);
      this.newTodo = "";
    },
    deleteTodo(i) {
      this.todos.splice(i, 1);
    }
  }
}
</script>

再度アプリを起動してみましょう。
また、Visual Studio Code内でターミナルを起動することも可能です。
上部メニューのターミナル>新しいターミナルから起動してください。

スクリーンショット 2020-07-04 20.44.07.png

起動コマンドは同じです。
スクリーンショット 2020-07-04 20.44.36.png

起動後、再度"http://localhost:8080/"にアクセスしてください。
以下のような画面になっているかと思います。
スクリーンショット 2020-07-04 20.57.08.png

入力フォームに適当な値を入力して、ADDボタンを押してみてください。
スクリーンショット 2020-07-04 20.59.26.png

ToDo Listに項目が追加されるかと思います。
スクリーンショット 2020-07-04 21.03.02.png

こちらが今回作成するアプリになります。
ここではアプリのイメージを掴むため、コピペしていただきましたが、以降では順を追って実装内容を説明していきます。
初学者の方は、App.vueの記載を全て削除して、一から自身でコーディングしてみることをおすすめします。

TODO追加機能の実装

では、実装内容を見ていきましょう。
一からコーディングされる方向けに説明していきます。

まず、App.vueの記載を全て削除し、以下のようにベース部分をコーディングしましょう。
機能は何もありませんが、起動すると静的な画面が表示されます。

App.vue
<template>
  <div>
    <h3>My TODO</h3>
    <input placeholder="Input here...">
    <button >ADD</button>
    <h5>ToDo List</h5>
    <ul>
      <li>
      </li>
    </ul>    
  </div>
</template>

<script>
export default {
  data() {
    return {

    }
  },
  methods: {

  }
}
</script>

入力フォームの作成

TODOを追加するには、input要素に入力された値をjavascript側で操作できるようにする必要があります。
Vue.jsではその紐付けのことをバインディングと呼んでいます。
input要素に入力された値をバインディングするには、v-modelディレクティブを使用します。

v-modelディレクティブについては、こちらを参照してください

ここでは変数名をnewtodoとしています。

template部分
<input v-model="newTodo" placeholder="Input here...">

合わせてscript部分のdataに変数newtodoを定義します。
return{}のなかに記載してください。
これで"newtodo"という変数のバインディングを定義したことになります。

data()部分
data() {
  return {
    newTodo: ""
 }
}

上記のように記載すると、input要素に入力された値をjavascript側で、"newTodo"という変数名で扱えるようになります。

TODOを格納する配列を定義

次にTODOの格納先を作成します。
今回は簡易的に配列に格納することにします。
Script部分のdata()にtodosという変数名で、空の配列を定義してください。
カンマ","を忘れないよう注意してください。

data()部分
data() {
  return {
    todos: [],
    newTodo: ""
 }
}

todosという変数とnewTodoという変数を定義したことになります。

クリックイベントの実装

TODOの追加方法ですが、ADDボタンが押されたら、配列に追加する仕様としましょう。
※このような仕様を説明する際は、ボタンのクリックイベントをトリガーにTODOの追加処理を行う、などと言います。

Vueでクリックイベントの実装には、v-onディレクティブを使用します。
ここではクリック時にaddTodo()メソッドを実行するよう定義しています。

template部分
<button v-on:click="addTodo()">ADD</button>

追加処理の実装

続いて、追加処理となるaddTodo()メソッドを定義します。
Vue.jsではmethods部分にメソッドを定義していきます。

methods部分
methods: {
  addTodo() {
    this.todos.push(this.newTodo);
    this.newTodo = "";
  }
}

まず、配列に追加する値を取得します。
data部分に定義された変数を呼び出すにはthisを使います。
this.newTodo でフォームに入力された値を取得することができます。
また、格納先の配列もthisを使って記載します。

追加処理は引数の値を配列に格納するpushメソッドを使用します。
以下のような実装をすることで、追加処理を行なっています。

this.配列の変数名.push(this.追加対象の変数名)

また、追加処理後に変数newTodoの値を空に設定します。
入力フォームの値をクリアしています。

this.newTodo = "";

※上記で説明したバインディングは、正確には双方向バインディングという機能です。
詳細はこちらを参照してください

TODOの一覧表示

追加したTODOを一覧表示する機能を実装していきます。
方針としては配列に格納された値を取得し、繰り返し処理を実施して、各項目を表示していきます。
Vue.jsで繰り返し処理を行うには、v-forディレクティブを使用します。
templateのli要素の部分を修正してください。

template部分
<li v-for="(todo, i) in todos" v-bind:key="i">
  {{ todo }}
</li>

v-for="(todo, i) in todos"と書くことで、配列todosから要素を1つ1つ取得し、todoという変数に格納しています。
{{ todo }}で変数todoに格納された値を表示しています。
v-bind:key="i"ではindex番号を格納する変数を定義しています。こちらは削除処理の際に、対象要素を指定するのに使用します。

チェック処理の実装

ここまででTODOの追加処理を実装することができました。
しかし、入力フォームが空のままADDボタンを押して見てください。
TODO名が空の項目が追加されていると思います。
これは本来の用途に沿わないため、仕様として不適切です。

これを回避するためチェック処理を実装します。
pushメソッドを実行する前に、以下のif文を追加してください。

methods部分
methods: {
  addTodo() {
    if (this.newTodo === "") return;
    this.todos.push(this.newTodo);
    this.newTodo = "";
  }
}

this.newTodoの値が空の場合、addTodoメソッドを抜ける(returnする)処理を実装しています。

TODO削除機能の実装説明

クリックイベントの実装

追加と同様にv-onディレクティブを使用して、DELボタン押下をトリガーに、TODOを削除する仕様とします。
メソッド名deleteTodo(i)メソッド
引数のiはindex番号が格納されています。

template部分
<li v-for="(todo, i) in todos" v-bind:key="i">
  {{ todo }}
  <button v-on:click="deleteTodo(i)">DEL</button>
</li>

削除処理の実装

methodsにdeleteTodoメソッドを実装します。
配列から要素を削除するには、spliceメソッドを使用します。

spliceメソッドは第一引数で開始位置を指定し、第二引数で削除する要素の個数を指定します。
spliceメソッドの説明

開始位置はインデックス番号と同じですので、第一引数にはiを指定し、削除個数は1要素だけですので、第二引数には1を指定します。

methods部分
  methods: {
    addTodo() {
      if (this.newTodo === "") return;
      this.todos.push(this.newTodo);
      this.newTodo = "";
    },
    deleteTodo(i) {
      this.todos.splice(i, 1);
    }
  }

これで一通りのTODO追加、削除機能が実装できました。

終わりに

お疲れ様でした。
次回はデータベースやAPIサーバを利用した実装を紹介したいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[JavaScript]テキストエリア間を矢印キーで移動したい

やりたいこと

  • テキストエリア間のフォーカスをKeyboardEventイベントで発火させて移動させたい
  • フォーカスをテキストエリアの最初とか途中とか任意の場所に移動させたい
    • テキスト最後尾で↓キーを押したら次のテキストの先頭に移動させる
    • テキストの先頭で↑キーを押したら前のテキストの最後尾に移動させる

結論

  • keydownイベントで現在のテキストカーソルの位置を判定する
  • keyupイベントでフォーカスの移動、フォーカスの位置の指定を行う

See the Pen moving_focus by hodaka (@cornercrap) on CodePen.

動機

フォームなどのフォーカスの移動にはtabキーを使うことが一般的かと思いますが、矢印キーで操作できた方が操作性も上がるかなと思い検証しました。しかし、思ったより難航したのでここに書き留めておきます。

主な登場人物

keydown/keyupイベント

それぞれキーを押したとき/離したときに発生するイベント

KeyboardEvent.code

押された/離したキーの種類

focus()

その要素がフォーカスされる

setSelectionRange(selectionStart, selectionEnd)

任意のテキストの開始と終了のインデックスを指定することでテキストを選択状態にする
→引数に同じインデックスを指定すればテキストカーソルの移動と同じ意味になる

今回操作するHTMLはこんな感じです。

HTML
<div>
  <textarea id="textarea">テキストテキスト</textarea>
</div>
<div>
  <textarea id="textarea2">テキスト2テキスト2</textarea>
</div>

失敗例1

まず手始めにkeyupだけでできるだろうと思い、組んでみました。

失敗例1
const textArea = document.getElementById("textarea");
const textArea2 = document.getElementById("textarea2");

textArea.addEventListener("keyup", event => { // use keyup event
  if(event.code == "ArrowDown" && textArea.selectionStart == textArea.value.length){
      textArea2.focus();
  }
});
textArea2.addEventListener("keyup", event => {
  if(event.code == "ArrowUp" && textArea2.selectionStart == 0){
      textArea.focus();
  }
});

カーソルがテキストの途中にあってもフォーカスが移動してしまいます。
↓キーを押すとテキストカーソルは、複数行ある場合は次の行へ移動する、最終行だった場合、最後尾に移動します。
最後尾に移動してしまうと後半の条件式が満たされてしまうのでフォーカスが移動してしまいます。

失敗例2

ならばとkeydownで試してみます。テキストカーソルの位置が想定どおりに行かないのでsetSelectionRange()で明示的に指定しています。

失敗例2
const textArea = document.getElementById("textarea");
const textArea2 = document.getElementById("textarea2");

textArea.addEventListener("keydown", event => { // use keydown event
  if(event.code == "ArrowDown" && textArea.selectionStart == textArea.value.length){
      textArea2.focus();
      textArea2.setSelectionRange(0, 0);
  }
});
textArea2.addEventListener("keydown", event => {
  if(event.code == "ArrowUp" && textArea2.selectionStart == 0){
      textArea.focus();
      textArea.setSelectionRange(textArea.value.length, textArea.value.length);
  }
});

setSelectionRange()を使ってもカーソルの位置が指定できなくなってしまいました。

成功例?

keydownの中ではsetSelectionRange()がうまく機能していませんでした。
しかし、調べていたところ、setTimeout()を使って回避する方法が見つかりました。こちらを参考にしてみます。

成功例?
const textArea = document.getElementById("textarea");
const textArea2 = document.getElementById("textarea2");

textArea.addEventListener("keydown", (event) => {
  if (
    event.code == "ArrowDown" &&
    textArea.selectionStart == textArea.value.length
  ) {
    window.setTimeout(() => { // use setTimeout()
      textArea2.focus();
      textArea2.setSelectionRange(0, 0);
    });
  }
});
textArea2.addEventListener("keydown", (event) => {
  if (event.code == "ArrowUp" && textArea2.selectionStart == 0) {
    window.setTimeout(() => {
      textArea.focus();
      textArea.setSelectionRange(textArea.value.length, textArea.value.length);
    });
  }
});

不思議なことに想定どおりに動いてくれています。とりあえず実装したい場合はこれでいいと思いますが、この解法に関しては最善策でないと述べられていますし、私自身動く理由がよくわからないので、もう少し模索してみます。

成功例(結論)

keyupではカーソルの位置が正しく判定されず、keydownではフォーカス後のカーソルがうまく指定できませんでした。
なので両方のイベントを使ってそれぞれの欠点を補います。

成功例(結論)
let movingFlg = false;

textArea.addEventListener("keydown", (event) => {
  if (
    event.code == "ArrowDown" &&
    textArea.selectionStart == textArea.value.length
  ) {
    movingFlg = true;
  }
});
textArea.addEventListener("keyup", (event) => {
  if (event.code == "ArrowDown" && movingFlg) {
    movingFlg = false;
    textArea2.focus();
    textArea2.setSelectionRange(0, 0);
  }
});

textArea2.addEventListener("keydown", (event) => {
  if (event.code == "ArrowUp" && textArea2.selectionStart == 0) {
    movingFlg = true;
  }
});
textArea2.addEventListener("keyup", (event) => {
  if (event.code == "ArrowUp" && movingFlg) {
    movingFlg = false;
    textArea.focus();
    textArea.setSelectionRange(textArea.value.length, textArea.value.length);
  }
});

keydownでは単に移動するべきかどうかを判定して、フォーカスやカーソル位置指定はkeyupで行います。フラグも出現してコード量も多くなりましたが腑に落ちるレベルまでは到達できました。

終わりに

改善案・ご指摘・捕捉など歓迎です。優しく教えてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

スクリプトレット:Udemy講座の目次を作成する

Udemy講座の目次を作成する

やること

Udemy講座の目次をぱぱっと作成してしまおう

できること

Udemy講座の目次がクリップボードにコピーされます。

やりかた

スクリプトレットでサクッと

以下をスクリプトレットとしてブラウザに登録してください。

javascript:document.querySelectorAll(".section--section--BukKG").forEach(el=>{  if(el.getAttribute("aria-expanded") == "false" ){    el.querySelector("[role=button]").click();  }}); function execCopy(string) {  var tmp = document.createElement("div");  var pre = document.createElement("pre");  pre.style.webkitUserSelect = "auto";  pre.style.userSelect = "auto";  tmp.appendChild(pre).textContent = string;  var s = tmp.style;  s.position = "fixed";  s.right = "200%";  document.body.appendChild(tmp);  document.getSelection().selectAllChildren(tmp);  var result = document.execCommand("copy");  document.body.removeChild(tmp);  return result;} var ary = [];document.querySelectorAll(".curriculum-item-link--title--zI5QT").forEach(el=>{ary.push(el.innerText.split("\n").join(""));});console.log(ary.join("\r\n"));execCopy(ary.join("\r\n"));

使い方

Udemyの講座ページを開いて実行します。

image.png

あとは、エディタに貼り付けて加工!

image.png

以上!

解説

整形
// javascript: 
// ★(1)ここで右側のプルダウンをすべて展開します!
document.querySelectorAll(".section--section--BukKG").forEach(el => {
  if (el.getAttribute("aria-expanded") == "false") {
    el.querySelector("[role=button]").click();
  }
});

// ★(2)クリップボードにコピーするための関数を定義します!
function execCopy(string) {
  var tmp = document.createElement("div");
  var pre = document.createElement("pre");
  pre.style.webkitUserSelect = "auto";
  pre.style.userSelect = "auto";
  tmp.appendChild(pre).textContent = string;
  var s = tmp.style;
  s.position = "fixed";
  s.right = "200%";
  document.body.appendChild(tmp);
  document.getSelection().selectAllChildren(tmp);
  var result = document.execCommand("copy");
  document.body.removeChild(tmp);
  return result;
}
// ★(3)要素を取得して配列に詰め込みます!
var ary = [];
document.querySelectorAll(".curriculum-item-link--title--zI5QT").forEach(el => {
  ary.push(el.innerText.split("\n").join(""));
});
console.log(ary.join("\r\n"));
// ★(4)配列をくっつけて、クリップボードにコピーします!
execCopy(ary.join("\r\n"));

あとがき

取得対象の要素名(クラス名など)が変わると動かなくなります。
2020/07/05現在動いているので目次を取りたい際は試してみて下さい。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

簡単なJavaScriptを使って、睡眠時間が6時間未満だと千鳥のノブから怒られる実装をしてみた。

仕様書

 ・JavaScriptでチェックボタンの有り無しで、条件分岐させて処理させる
 ・チェックボタンは2つ用意し、4パターン条件をかく
 ・診断ボタンを押すと、診断結果を新らしく表示させる
 ・診断ボタンの下にidやクラスのみ記述し、そこに文章が追加されるようにする
 ・診断ボタン及び、追加で新しく表示させる文章にanimate.cssを使用

実装画面

ezgif.com-video-to-gif (9).gif

昨日の睡眠時間は?っという質問にすればよかった・・・・あとで気づきました。

コード(HTML,CSS,JavaScript書いてます)

<!DOCTYPE html>
<html lang="ja">
  <head>
    <title></title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link
    rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.0.0/animate.min.css"/>
  </head>
  <body>
    <div id="sleeping">睡眠時間についてお聞きいたします<br>

    <form name="form1" action="" class="form1">
      <input id="Checkbox1" type="checkbox"/><label for="Checkbox1">睡眠時間6時間未満</label><br/>
      <input id="Checkbox2" type="checkbox"/><label for="Checkbox2">睡眠時間6時間以上</label><br/>
      <input type="button" value="診断" onclick="onButtonClick();" class ="animate__animated animate__rubberBand"/>
    </form>
    <div id="output" class="animate__animated animate__heartBeat output"></div>
  </div>
  </body>

  <style>
    #sleeping{
      font-size: 25px;
      text-align: center;
    }
    #sleeping .form1{
      font-size: 15px;
    }
    #output{
      font-size: 30px;
    }
    .animate__rubberBand,
    .output
    {
      animation-iteration-count: infinite;
    }

  </style>

  <script type="text/javascript" language="javascript">
    function onButtonClick() {
        check1 = document.form1.Checkbox1.checked;
        check2 = document.form1.Checkbox2.checked;
        target = document.getElementById("output");

    if (check1 == true && check2 == false) {
          target.innerHTML = "寝ろ!脳が疲れとるかもしれん(千鳥のノブ風)";
       }  else if(check1 == true && check2 == true){
        target.innerHTML = "両方選択すなー!(千鳥のノブ風)";
       } else if(check1 == false && check2 == false){
        target.innerHTML = "どちらかを選択せぇ(千鳥のノブ風)";
    };

    if (check2 == true && check1 == false) {
          target.innerHTML = "よだれダコくん睡眠は十分、明日も頑張れ";
    };
    }
  </script>
</html>

なんだかノブの声が聞こえてくるような・・・・

簡単な解説

診断ボタンにonclick属性を追加

<input type="button" value="診断" onclick="onButtonClick();" 
class ="animate__animated animate__rubberBand"/>

↑onclickに記述しているのはJavaScriptで実装する関数名ですね。これ便利。

ちなみに、このanimate__animatedと記述しているのは、animate.cssを使用する際に必要となります。
バージョン4になってからの情報が少ないため、animate.cssが動かなかった場合公式ページをみるといいかもしれませんね。

animate__rubberBandはanimate.cssのアニメーションの効果です。色々用意されているので、公式ページより選んでみてください。

超簡単にanimate.cssを実装する方法を詳しく

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.0.0/animate.min.css"/>

<head>に追加。

アニメーションしたい箇所にクラスを設けて、クラス名に
animate__animated ○○
○○には追加したいアニメーション名を記述する(デフォルトの設定が動きます)

実装した感想

チェックの有無を簡単なJavaScriptで実装できるのは良いなと思いました。
あと、チェックボタンが押された瞬間に動作するような実装やJQueryで書いてみたりしてみるともっと面白いものができそうだなぁと思いました。

PS.
アニメーションでノブのツッコミの画像が出てくると面白そうですね。
あと読み上げソフトで発火した文章を読み上げてくれるか、ノブの声に近づけた声を作って読み上げさせたり、やりたいこと挙げると私はきりがありません。。。

animate.cssで出来ると思うので、ご興味ある方追加して遊んでみてください。

また、ここは違う、ここはこうしたほうが良いかも?等々ございましたら、ご指摘いただけますと幸いです。
最後までみていただき、ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

本棚で考えるJavascriptの配列操作

javascriptのオブジェクトを本棚に見立てて
色々操作してみる。

const books = {
    novel: {
      name: ['火花', '赤毛のアン', '星の王子様']
    },

    comic: {
      name: ['鬼滅の刃', 'ONE PIECE', 'キングダム']
    },

    business: {
      name: ['7つの週間', 'サピエンス全史', '自助論']
    },

    philosophy: {
      name: ['方法序説', '死に至る病', 'ニコマコス倫理学']
    },

    biography: {
      name: ['フランクリン自伝', '果てなき野望', 'マイ・ストーリー']
    },

    favorite: {
      name: ['FACTFULLNESS', '蹴りたい背中', '恋は雨上がりのように']
    },
}

console.log(books);

Q1

新しく進撃の巨人を買ったので本棚に追加する

  • comicに進撃の巨人を追加(先頭に)

A

books.comic.name.unshift('進撃の巨人');

ちなみに末尾に追加するにはpush

Q2

7つの習慣を読みたくなってきた

  • 7つの習慣を取り出す
  • 「7つの習慣を読み終わりました」と出力する
  • businessの棚の一番最後に戻す。

A

const choose = books.business.name.shift();
console.log(choose + 'を読み終わりました');
books.business.name.push(choose);
console.log(books);

7つの習慣を読み終わりましたと出力された後に
business.nameの配列の最後に7つの週間が追加されていればOK

Q3

「赤毛のアン」がとても面白かったのでお気に入りの棚に追加しようと思ったが、お気に入りの棚がもういっぱいで本が入らない!

仕方ないので、「恋は雨上がりのように」は漫画の棚に移動する

  • 赤毛のアンを取り出す
  • 恋は雨上がりのようにを取り出す
  • 赤毛のアンをfavoriteに追加
  • 恋は雨上がりのようにcomicに追加

A

const read = books.novel.name.splice(1, 1);

console.log(read + 'を読みました')

//読んだ後にパンパンだと気づく

const move = books.favorite.name.pop();

books.comic.name.push(move);
books.favorite.name.push(read);

console.log(books);
  • novelが2つになって
  • comicに恋は雨上がりのようにが追加されて
  • favoriteに赤毛のアンが追加

されてればOK

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

初めてのJavascript

お題

https://prog-8.com/courses
無償で基本が勉強できるサイトの JavaScript 第1章の演習を行った

内容

  • 文字列と数値
  • 変数と定数
  • 条件分岐

文字列と数値

  • 文字列を出力したい場合

console.log(“Hello World”);
文字列は “ か ‘ で囲う

  • コメントを記載したい場合
    //コメント (ショートカットキー command + /)

  • 数値を出力する場合
    文字列のように “ や ' は不要

a + b
a - b
a / b
a * b
a % b(余りを求める)

% は奇数か偶数かを判断するときに使う

省略形の書き方

スクリーンショット 2020-07-05 14.33.16.png

変数と定数

変数

let 変数名 = 値として定義
プログラミングの「=」は「等しい」という意味ではなく、「右辺を左辺に代入する」という意味
let」は「これから変数を定義します」という宣言
let name = “John”;

命名規則

英語 number
2語以上の場合は大文字で区切る oddNumber

変数の中身の更新

name = “Kate”;
※ let は不要

定数

const 変数名 =値として定義
※定数は値を更新することは不可

let と const の違い

スクリーンショット 2020-07-05 14.36.06.png

テンプレートリテラル

文字列の中で「${定数}」とすることで、文字列の中に定数や変数を含めることができる
文字列全体はバッククォーテーション(`)で囲めばよい
スクリーンショット 2020-07-05 14.38.22.png

条件分岐

スクリーンショット 2020-07-05 14.41.51.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Web屋がJavaScriptでゲームを作ってSteamで配信するまでの道のり

大目次:

はじめに

「Web技術でゲーム開発」に関する日本語記事自体がそれほど多くはないですが、
特に「HTML5製のゲームをSteamで公開」的な情報となると全く見つからなかったので、
先日公開したばかりのゲームについて書いてみます。

「Webエンジニアならでは」を多少意識しつつ、開発からリリースまでにやってきたことを広く浅くまとめました。

成果物 『Unsung Kingdom

title.png
multiple.png

HTML5ベースのオーソドックスなRPGです。

Unsung Kingdom
ジャンル RPG
価格 無料
プレイ時間 3-4時間
配信先 Steam / GooglePlay / Web

そもそもなぜゲームを作るのか

主語を僕に置くと、「趣味」の一言で終わってしまうんですが、
まずは、「エンジニアが業務外で何かを作るメリット」を考えてみます。

  • プログラミング能力の向上につながる
  • エンジニアとしてのプロフィールになる
    • 例え有名アプリのエンジニアをしていたとしても業務で書いたソースコードを社外に示せるケースは少ないですね
  • ポートフォリオとしても使える
    • 僕はずっとWeb業界志望ですが趣味で作ったゲームも転職の際に使っています

「ゲーム」である必要はありませんが、僕が色々作ってみて思った印象です↓

感想 作例
ツール系アプリ これは個人でも役に立てるものを作れる可能性が高めかも。
ただし利用者がついたならメンテを続けなければいけない。
マークダウンのメモアプリ
ユーザー参加型のWebサービス 個人で流行らせるのは難易度が高すぎ!
(個人という時点で色々な面での信頼性が下がるし、広告費もかけられない)
そして使ってもらえず過疎ってしまうと哀愁がすごい。
オンライン絵しりとり
一発ネタ的アプリ 気軽に遊んでもらえて、話題性にも強いかも。
ただし賞味期限が短い気がする。新しい技術を試すためにサクッと作るのにおすすめ。
顔写真に泣きぼくろをつけるサイト.
ゲーム(notオンライン) 完成さえすればその時点で「作品」なので失敗の概念がない。
継続的なメンテは必要なく賞味期限も長い。
もちろんあまり遊んでもらえない可能性や批判的なレビューをもらう可能性はある。
-

そもそもなぜSteamで公開するのか

この記事ではSteamにフォーカスしましたが、実際はこのゲームはWeb上から直接遊べるし、WebViewでラッピングしてGooglePlayにも公開しています。

SteamとGooglePlayに出した最初の理由は、大きなプラットフォームの力を借りて集客するためです。
LPだけオープンして待っていたとこで誰も遊びに来てはくれないわけです。

なので正直、「Webブラウザで遊べるのに、集客のためだけにわざわざダウンロードしてもらうなんてアホくさいな」、と思っていました。

しかし今となっては、むしろSteam経由で遊んでもらいたい思いのほうが強いです。

Steamのストアに並ぶことは思っていたよりも嬉しくて、
例えるなら、小説を書いたとして、今まではコピー用紙に印刷してホチキスで止めたものを皆に配っていましたが、
今回はちゃんと本になって、カバーがついて、書店に並んだような、そんな気分です。

itch.ioと比べて

ちなみに以前、ブラウザゲームのプラットフォームとして世界的に有名なitch.ioにもゲームを投稿してみたことがあるんですが、全然遊ばれず、埋もれていってしまいました。

僕のゲームに魅力がなかったことには間違いないのですが、投稿量が多いためなのか、YouTubeのように人気なもの以外はすぐに埋もれて二度と浮上できないような難しさを感じました。

あと、日本人ユーザーが少ないと思います。

ゲームの実装

ライブラリ選び

僕はLinuxユーザーなので、ここ数年、世の中色々なものがWebベースで作られたり、クロスプラットフォームの方向に広がっている状況には歓喜しています。

なのでもちろんゲームもWebで作っていきたいと思います。

色々調べたところ、Phaser3というライブラリに行き着きました。
68747470733a2f2f7068617365722e696f2f696d616765732f6769746875622f3330302f7068617365722d6865616465722e706e67.png
日本では無名ですが、すごいスター数です?

そしてアップデートがとても活発です。Web界隈のスピード感そのものですね。

これだけでPhaser3を使うことは確定しました。

開発

例によってwebpackなどをベースに好みの開発環境を作ったら、あとはPhaser3の公式リファレンスを片手にひたすらイケイケなコードなコードを書いていきましょう?

Phaser3は、(≒ゲームライブラリは、)
画像や図形、文字の描画、物理演算といったゲームを作るうえで必要となってくる基本的な機能を提供します。

キーを押すとキャラクターが歩いたり、会話ウィンドウが出てセリフが表示されたり、といった直接ゲームの要素となるような機能は提供されません。

RPGでいうと、フィールドやイベント(ストーリー)といった「中身」を作っていく前に、↑こういったシステムの枠組みを作っていくことになります。

Phaser3では、GameObjectという座標情報などを持った基本となるクラスがあり、これを継承しながらキャラクターやUIなどのコンポーネントを作っていく感じになると思います。

これをゲームの空間+時間となるSceneというクラスに配置し、動作させていきます。

細かな実装内容についてはとても記事にはしきれませんが、例として今回作ったクラスを一つリンクします。(これだけ見ても謎かもしれませんが…)

Character.js: これは、マップ上を歩き回るキャラクターのためのクラスで、GameObjectから継承され、さらにPlayerとNPC用の子クラスに派生します。

Webサイト、Webアプリ開発との違い

僕が思うWebサイトの開発と意識するところの違いは、「時間」の要素がより強く作用してくる点だと思います。

常にフレームレートに応じたループが走っており、この流れの中で画面に表示されるべき状態を実現したり、ユーザーからの入力を作用させたりしていきます。

DOMもほぼ使わなければ、そもそも実現する内容もWebとは全く異なりますが、↑の感覚が掴めてきたらなんてことはありません。

意識高い系コードレビューをくぐり抜けてきた我々の、プログラミングそのものの力量は存分に活かせるはずです。

辛い点・反省

辛かったのは、「見た目」の実装で、HTMLやCSSのような構造や体裁を定義する仕組みがない点で、
イメージとしては、JSによるDOM操作だけでWebページのコーディングをした感じです。

そのノリでViewにあたる部分を実装していくと、手続き的でController内に紛れるようなコードになりがちです。

次回はViewの実装をリアクティブな形で作れないか挑戦してみたいと思っています。

トラブルシューティング

Phaser3は日本では無名に近いです。
なのでPhaser3に関する情報を日本語でググったりするのは大抵無駄に終わります。孤独を感じます。

(わずかながら日本語情報を発信してくれている素晴らしい記事もあります!)

機能に関しては公式のドキュメントでほとんどのことが解決できますが、それ以外のトラブルは英語でググりましょう。

これは日本人ユーザーが多い技術について調べるときであってもおすすめしたいです。
目当ての情報を素早く見つけられる確率が段違いです。

もしもライブラリレベルで問題があるなら、オープンソースである強みを生かし、
ソースコードを読んでみたり、自分で直してプルリクを出したりしちゃいましょう。

僕は今回のゲームを開発中にPhaser3自体の不具合で行き詰まってしまったので、
修正のプルリクを出しつつ、改善されるまでの間は自分でフォークして修正したブランチを使って開発していました。

その後、翌月のリリースにてすぐ修正されていました。
ほんのわずかであれ、お世話になっているライブラリに貢献できたのは幸せな体験の一つでした。

Steamに出すために

Screenshot from 2020-07-05 14-17-01.png

ゲームをSteamで配布できる状態にする

ネイティブアプリ化と、Steam SDKの導入が必須となります。

NW.jsというフレームワークでサクッとネイティブ化し、
Steam SDKをNode.jsでラッピングしたgreenworksというライブラリを導入します。

これについては、実際の作業手順を別の記事「HTML5製のゲームにSteamSDKを導入する手順(NW.js + Greenworks)」にまとめました。

↑の作業さえ無事に終われば、HTML5ベースのゲームであることが障壁になることはこれといってありませんでした。

Steamの機能への対応

Steam SDKさえ導入できればとりあえずパブリッシュ条件は達成できます。

しかし、せっかくなのでSteam SDKの機能を使ってゲームを充実させましょう。

僕は、Steamからも「必須じゃないけどあったほうがいいよ〜」と推奨されている実績機能クラウドセーブを導入しました。
少なくともこの2つはかなり簡単でした。

Screenshot from 2020-07-05 03-27-10.png

実績があるだけでSteamで出している感が増しますね。

SteamDirectへの参加

SteamDirectというプログラムを通じて、一般人でもより気軽にSteamにパブリッシュができるようになりました。

なりましたと言いつつ過去の事情に詳しいわけではありませんが、以前はもっとハードルが高かったようです。

ざっくりやること
基本情報の登録 組織名とかですね。
銀行口座の登録 売上金の振込先です。
税務情報の提出 全部Webで完結できますが、色々回答項目が多いです。
この項目でTIN(納税者番号)としてマイナンバーカードを提出しないと日本と米国で二重に徴税されるらしいです。
KYC(顧客確認) ↑に関連する…?
よく分かりませんが免許証を提出しました。
アプリ提出料の支払い 100ドル支払う必要があります?
100ドルということは多分100円くらいなので無心で払いましょう。

口座と税金については、公開するゲームが無料配布の予定でも必要です。

多分税金情報のところが一番大変そうな感じがしましたが、いずれも一つ一つ進めていけば解決できるもののはずです。

KYCは代行会社からメールが届いて、そこに免許証の写真を返信したのですが、
この代行会社からのメールや、コーポレートサイトがなんとも怪しくて(httpだし…)、詐欺メールじゃないかと疑ってしまいました。

一連の作業から5日後に無事デベロッパーになれました。
僕が使ったのは、口座情報、マイナンバーカード、免許証でした。

またろうのSteamworksブログ』 ←こちらのブログにとてもお世話になりました。

SteamWorksでリリースの準備

リリースを管理するためのSteamWorkの画面にアクセスできるようになります。

やることは大きく以下の2点です。

こういうのを用意します
ストアページの準備 - 紹介文
- トレイラー・スクリーンショット
- その他カバー画像やバナーなど
ビルドの準備 - ゲームのzipをデプロイ
- 起動コマンドの設定

ストアページとビルドは、それぞれSteamのレビューを通過する必要があります。
レビューは1,2営業日でフィードバックがもらえます。

また、内容もそれほど厳しくなさそうです。

(えっちな内容やセンシティブな内容を含むゲームはその限りではないかも)

本国で対応しているのか分かりませんが、レビュー結果は英語で返ってきます。
いずれにしても、SteamWorksの推奨通りに進めていればレビューで大きくつまずく可能性は低そうです。

SteamWorks内では、どのようにストアを用意するのが遊んでもらうために効果的かを導いてくれるため、安心感がありました。

リリース!

ゲームがリリースできるのは、ストアページが公開されてから2週間後です。

リリース当日は「アプリケーションをリリース」と唱え、晴れてリリースに至ります。

release.png

その他にやったこと

宣伝まわり

プレスリリース:
ゲームのニュースやレビューを出しているようなサイトは、メールやフォームで掲載依頼を受け付けています。
プレスリリース(こんなの作ったよ的な紹介をまとめた文書)を作って送ってみました。

結果、4GamerさんとGamerさんがリリース文まま掲載してくれました。

ツイッター:
多分もっと開発段階から進捗を発信したりして、少しずつフォロワーを集めるといいんですよね。僕はそれが下手なんですが。

LP:
Web屋ならLPを作るくらい朝飯前のはずです!
今どきはバニラJSとCSS animationだけでもそれっぽく動かせますね。

Steam:
Steam自体の集客力が強いので、
もしかしたら特になにもしなかったとしても、数人にしか遊んでもらえないという事態にはならないんじゃないかと思いました。

ちなみに僕のゲームの場合、ストアページを公開してからの2週間で200名ほどにウィッシュリストへ追加いただけました。

多言語対応

昔作ったゲームの話ですが、中国語圏の人から、中国語に翻訳したバージョンを公開してもいいかと連絡をもらったことがあります。

それを追いかけたところ、中国語のフォーラムで僕のゲームがレビューされていたり、中国語版の僕のゲームの実況動画が作られているのを見つけました。

海外にだって無名作者のフリーゲームを好き好んで遊ぶ人はいるのだということを目の当たりにしました。

そんなこともあり、インターネットで世界が繋がっているというのに、日本語版だけしか出さないのは、実はあまりに勿体無いんじゃないかと思っています。

とはいえ自分一人でどうにかできるものでもないので、もしもに備えて多言語対応の仕組みを自作し、翻訳作業がしやすい状態にだけしました。

もしかしたら英語の学習も兼ねて自分で英語版の翻訳に挑戦してみるかもしれません。

オープンソースでの公開

GitHubで公開中です!

目的は、

  • 僕の最新のコードをGitHub上で見れるようにする
  • Phaser3でRPGを作りたい人が参考にできるようにする
  • 先述した多言語対応をもし万が一やってくれる方が居たら参加できるようにする

です。

ただし、ゲームによっては以下のような障壁があるかもしれません。

  • もし作ったゲームが有料作品なら、ビルドすれば遊べてしまうので、買った人が損をする
  • もし作ったゲームにオンラインで競う要素(ランキング機能を含む)があるなら、作りによってはより簡単に不正できてしまう

僕のゲームはどちらでもないのでノープロブレムです。

記事を書く

Web界隈にいる皆さんなら、技術や情報をオープンにしていくことがいかに素晴らしいかを知っており、
そしてその恩恵を受けていることと思います。

役に立つ自信がなくても何か書きましょう✊

僕は、Phaser3のTipsを少しずつ記事にしていこうかと思っています。

おわりに

僕は今までちょくちょくフリーゲームを作ってきましたが、Steamへは初のパブリッシュでした。

感想としては、本当に挑戦してよかったです。

冒頭で書いた通り、自分のゲームがSteamに並んだことが思っていたよりも嬉しいです。


JavaScriptをひたすら書けるのは楽しいですね。

綺麗な設計や実装は結構得意なつもりですが、これだけ大量に書くと粗が目立つ部分がでてきます。
そのぶん、次回はもっと綺麗に書ける、書こう、という気持ちになっています。


長くなりましたが、読んでいただきありがとうございました!

もしよければプレイ・シェアなどいただけたら嬉しいです!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

javascript 荷物運びゲームを自分なりに解釈 ビット演算子

今回も、書籍ゲームで学ぶJavascript入門で紹介されている
運び屋さんゲームの知識定着のために自分なりに解説をしてみます。

ゲーム内容はこちらと同じです。
これを発展させると
switchのファミコンに入ってるアドベンチャーズ オブ ロロのようなゲームになると思います。

ビット処理が出て、よりゲームらしい実装なので
頑張って読み解いていきます。

解説が間違ってたらごめんなさい。

この記事では、これだけわかったらOK

2進法の0と1を活用するビット演算の良い所は、
ある一つの部分を調べたら目的の値だけが絞られることです。
「靴を見れば、おしゃれか分かる」
「2ビット目が0か1で、移動できるか分かる」

2ビット目だけを直接取り出す関数がないので、
意図的に計算して、出た結果から判断をしているだけです。

ビット演算子

荷物が壁に進むなら、動かない。
荷物が道に進むなら、動く。

これはすべてif文で処理ができます。
ここでビット演算を取り入れたら、よりスッキリなコードができます。
ファミコンのマリオブラザーズの容量が40KBしか無いので、簡略にする意味があります。

ビットは、0と1の2進法で表す表記です。
0がOFF、1がONを表します。

AND演算子は、お互いの数字が1のONなら、1のONになります。  1 and 1 = 1

OR演算子は、どちらかの数字が1のONなら、1のONになります。  1 or 0 = 1

また2進法と10進法の表がこちらです。

2進数 10進数
0000   0
0001   1
0010   2
0011   3
0100   4
0101   5
0110   6

10進法の1と2をAND演算子とOR演算子で計算すると

1は  0001
2は  0010

AND演算子は、上下の0と1をそれぞれ比較して
同じ場所で1が重なるところがないので、0000となります。つまり10進法で0です。

0001
0010
:frowning2::frowning2::frowning2::frowning2:
0000 →10進法で0

OR演算子は、上下の0と1をそれぞれ比較して
どちらかが1になる場所は2か所あります。0011となります。つまり10進法で3です。

0001
0010
:frowning2::frowning2::relaxed::relaxed:
0011 →10進法で3

地図の表現

地図となる配列がこちらです。
ここでは40pxの正方形の絵を、積み重ねてマップを構成します。
その数字によって描く絵が変わります。6が壁 0が道 2が荷物 1がゴールです。

map.js
var data = [
    [6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6],
    [6,6,6,6,6,0,0,6,6,6,6,6,6,6,6,6,6,6,6,6],
    [6,6,6,6,6,2,0,0,6,6,6,6,6,6,6,6,6,6,6,6],
    [6,6,6,6,6,0,0,0,0,0,6,6,6,6,6,6,6,6,6,6],
    [6,6,6,0,0,2,0,0,2,0,6,6,6,6,6,6,6,6,6,6],
    [6,6,6,0,6,0,6,6,6,0,6,6,6,6,6,6,6,6,6,6],
    [6,0,0,0,6,0,6,6,6,0,6,6,6,6,6,0,0,1,6,6],
    [6,0,2,0,0,2,0,0,0,0,0,0,0,0,0,0,1,1,6,6],
    [6,6,6,6,6,0,6,6,6,6,0,6,0,6,0,0,1,1,6,6],
    [6,6,6,6,6,0,0,0,0,0,0,6,6,6,6,6,6,6,6,6],
    [6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6],
];

//6が壁 0が道 2が荷物 1がゴール

var gc
var px = 12,py = 8

//gc 絵を描く場所
//pxとpyがキャラクターがいる場所です。


function init(){
 gc = document.getElementByID("soko").getContext("2d");
 onkeydown = mykeydown; // キーを押すと、オリジナル関数mykeydownが発動
 repaint(); //上のdataを元に、マップを描画する
}

そしてhtmlはこうなります。

map.html
<body onload ="init()">
<canvas id="soko" width="800" height="440"></canvas>
<img id ="imgWall" src="imgWall.png" style="display:none" />
<img id ="imgGoal" src="imgGoal.png" style="display:none" />
<img id ="imgWorker" src="imgWorker.png" style="display:none" />
<img id ="imgLuggage" src="imgLuggage.png" style="display:none" />

画像は読み込んでいますが、非表示をします。
repaintの中で、画像を選択して描画するときに使います。

余談ですが下のリンク先の人は、javascriptでマリオブラザーズを作成しています。
javascriptマリオブラザーズ

あらかじめマリオに出てくるアクションを1枚にまとめて、画像の読み込みを1回で終わらせる方法をしています。後は画像の座標を指定すれば、表示されたい画像の一部分だけを表示させています。

物を動かす どこの配列を見るか

矢印キーを押したときに、
矢印の先によって、動作を変える必要があります。
矢印の先が荷物。さらにその先が道なら、荷物が移動させる動作をさせる指示を
矢印の先が荷物。さらにその先が壁なら、荷物が動かない動作をさせる指示をさせます。

map.jpg

現在が真ん中のpx pyとすると
上ボタンをおすと、yの値だけ変わります。
dy0 = py+1
dy1 = py+2
px=dx0=dx1 (xは変わらない)

action.js
function mykeydown(e){
 var dx0=px,dx1=px,dy0=py,dy1=py //ひとまず真ん中の値で、xとyの値にしておく

//どのボタンを押したかで処理を変える 
    switch (e.keyCode){
        case 37: dx0--; dx1 -= 2; //左
        break;
        case 38: dy0--; dy1 -= 2; //下
        break;
        case 39: dx0++; dx1 += 2; // 右
        break;
        case 40: dy0++; dy1 += 2; //上
        break;
    }

/*
左を例にすると
pxが原点
dx0-- は dx0 = dx0-1の略  pxから1つ左にずれた所
dx1-=2 は dx1 = dx1-2の略 pxから2つ左にずれた所

yは変わりません。

*/

テキストにはこのような表記が出てきます。

action.js
if((data[dy0][dx0]&0x2)==0){
}

矢印を押すとキャラクターが動作します。
キャラクターが動いた先が、道やゴールなら、そこにキャラクターを移動できます。
キャラクターが動いた先が、壁や荷物ならキャラクターは移動できないとします。

動いた先が道0なら動く    0は2進法 00 0 0
動いた先がゴール1なら動く  1は2進法 00 0 1
動いた先が荷物2なら動かない 2は2進法 00 1 0
動いた先が壁6なら動かない  6は2進法 01 1 0

2ビット目が0か1で動くか判定できそうですね。

0x2は「10進法で2」を表します。なので 00 1 0
それをAND演算しています。

道0 and 2なら
0000
0010
:frowning2::frowning2::frowning2::frowning2:
0000

壁6 and 2なら
0110
0010
:frowning2::frowning2::relaxed::frowning2:
0010

さきほどこちらの表記はこういう意味です。

action.js
if((data[dy0][dx0]&0x2)==0){ //行き先が壁でも荷物でもない時
 px = dx0;
  py = dy0; //キャラクターは行き先に行ける
}

else処理として、日本語で書くと

「行き先が荷物の場合、
 さらにその先が道なら、キャラクターを動かす。荷物も動かす

 行き先が荷物の場合、
 さらにその先が壁なら、動かない」

先ほど文をコピペ

動いた先が道0なら動く    0は2進法 00 0 0
動いた先がゴール1なら動く  1は2進法 00 0 1
動いた先が荷物2なら動かない 2は2進法 00 1 0
動いた先が壁6なら動かない  6は2進法 01 1 0

action.js
}else{if((data[dy0][dx0] &0x6)==2)

行き先の値が荷物2で0010 それと0x6をAND演算子で処理をしています。
0x6は10進法6の意味です。つまり2進法なら0110

0010
0110
:frowning2::frowning2::relaxed::frowning2:
0010→2

正直はここはAND演算子の計算を使わず
直接data[dy0][dx0]==2 でいいと思います。

ビット演算の良い所は、
ある一つの部分を調べたら目的の値だけが絞られることです。
「靴を見れば、おしゃれか分かる」
「2ビット目が0か1で、移動できるか分かる」

2ビット目だけを直接取り出す関数がないので、
意図的に計算して、出た結果から判断をしているだけです。

テキストではこのように続きます。

action.js
}else if((data[dy0][dx0] & 0x6)==2){ //行き先が荷物
 if((data[dy1][dx1] & 0x2) ==0){ //さらに先が荷物なし、壁なし
 data[dy0][dx0] ^= 2;
 data[dy1][dx1] |= 2;
  px = dx0;
  py = dy0;
 }
}

ややこしい...
ここでネックとなるのが、ゴールと荷物の関係です。
ゴールの上に置かれた荷物を再度、道側に置いた時に元の場所はゴールに戻しておく必要があります。
map2.jpg

黄色がゴール 青が荷物とします。
荷物がゴールを破壊してはダメなんです。
ゴールと荷物が置かれた状態を別の数字で表現する必要があります。

^は「ビット排他的論理和 (XOR)」です。
|は「ビット論理和 (OR)」です。

ここではまず後者のORから説明します。

OR演算子

data[dy1][dx1]|=2 はdata[dy1][dx1]= data[dy1][dx1] or 2の略です。

道0    0は2進法 0000
ゴール1  1は2進法 0001
荷物2   2は2進法 0010
壁6    6は2進法 0110


道0 OR 2(0010)
0000
0010
:frowning2::frowning2::relaxed::frowning2:
0010→2

ゴール1 OR 2(0010)
0001
0010
:frowning2::frowning2::relaxed::relaxed:
0011 →3

荷物2 OR 2(0010)※この組み合わせはifで弾かれます。
0010
0010
:frowning2::frowning2::relaxed::frowning2:
0010→2

壁6 OR 2(0010)※この組み合わせはifで弾かれます。
0110
0010
:frowning2::relaxed::relaxed::frowning2:
0110 →6


0010 荷物 2
0011 荷物+ゴール 3
1ビット目が0か1で置かれた状況を変えるようなイメージです。

map2.jpg

0011 荷物+ゴール 3を
0001 ゴール 1に戻す方法はないか
 

XOR演算子

data[dy0][dx0] ^= 2 は
data[dy0][dx0] = data[dy0][dx0] ^ 2 の略です。

XORは、すれ違いがあるときが1になる計算です。
1 xor 1 = 0
1 xor 0 = 1
0 xor 1 = 1
0 xor 0 = 0

or演算の違いは、
1 or 1 =1 となります。xorは常にすれ違いの時 だけが 1となります。

2は2進法では0010です。

道0    0は2進法 00 0 0
ゴール1  1は2進法 00 0 1
荷物2   2は2進法 00 1 0
壁6    6は2進法 01 1 0


道0 XOR 2(0010)
0000
0010
:frowning2::frowning2::relaxed::frowning2:
0010→2

ゴール1 XOR 2(0010)
0001
0010
:frowning2::frowning2::relaxed::relaxed:
0011 →3

3は0011でしたので先度計算させると
重なった3 XOR 2(0010)
0011
0010
:frowning2::frowning2::frowning2::relaxed:
0001 →1 ゴール

荷物2 XOR 2(0010)
0010
0010
:frowning2::frowning2::frowning2::frowning2:
0000→0

壁6 XOR 2(0010)
0110
0010
:frowning2::relaxed::frowning2::frowning2:
0100 →4


map2.jpg

ゴールが破壊されずに済みました。

action.js
}else if((data[dy0][dx0] & 0x6)==2){ //行き先が荷物
 if((data[dy1][dx1] & 0x2) ==0){ //さらに先が荷物なし、壁なし
 data[dy0][dx0] ^= 2; //隣の荷物を消す
 data[dy1][dx1] |= 2; //さらに隣に荷物をセットする
  px = dx0;
  py = dy0;
 }
}

描画する

最初に決めたdataのマップの数字を変えるだけなので、
それを元に再描画する処理です。

action.js
function repaint(){
 gc.fillStyle ="black"; //黒い
 gc.fillRect(0,0,800,440); //正方形を描画する

for(var y = 0;y<data.lenght;y++){
 for(var x = 0;x<data[y].lenght;x++){
 if(data[y][x]&0x1){ 
//直接==1 としないのは、ゴールが荷物に上塗りされないようにするため
//ゴール1 0001 ゴールと荷物3 0011 つまり1ビット目が1かどうかで判断される
 gc.drawImage(imgGoal,x*40,y*40,40,40)
}

if(data[y][x]&0x2){
//直接==2とすると、ゴールに荷物がついたときに荷物が消されます。
//荷物0010 荷物とゴール 0011 2ビット目が1かどうかで判断できる
gc.drawImage(imgLuggage,x*40,y*40,40,40)
}

if(data[y][x] == 6){
//壁はどんな時も壁なので、直接
gc.drawImage(imgWall,x*40,y*40,40,40);
}
}
}
gc.drawImage(imgWorker,px*40,py*40,40,40); //キャラクターを描く
}

後書き

if文で直接値を聞いて確認するのに慣れているので、ビット演算子で考える方法は斬新でした。
でもビット演算子で考えると、1ビット目、2ビット目に役割を持てるので判断の幅が広がります。
直接業務に役立つかわかりませんが、テトリスやマリオにはこの仕組みで計算されていると思うと面白い。
最近配布されたぷよぷよプログラミングも同じ考え方があると思うのでちょっと触ってみます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Node.jsのAPIをまとめてみた

はじめに

先週ぐらいからNode.jsの参考書を読み始め、途中から
■■ = require('〇〇')
というのが連発してきて、いろんな種類のAPIが使えるのだと知りました。

参考書の最初は「require('events')」や「require('http')」を使っていて、
他にどんなAPIが使えるのか気になり、一覧としてまとめてみました。

※初学者のため誤りがあると思います。
 間違いはコメントしていただけると幸いです。:bow_tone1:

本題の前に

本題の前に、「require」について少しだけ触れておきます。

requireとは、モジュール化されたJavaScriptのファイルをNodeから読み込んで利用できるようにしてくれるものです。

npmを使ってインストールしてきたパッケージを利用する際にもこの「require」を使用します。

著者は普段、C言語を使用していますので、この「require」はC言語のインクルードみたいなものだと理解しています。(あくまでもイメージです。)

本記事は、Nodeをインストールしたらデフォルトで使用できるモジュールについて一覧にしてみました。

Nodeモジュール一覧

下表はNode.js v12.18.2 ドキュメントを参考にまとめました。
公式ドキュメントにはそのAPIの安定性が記載されています。
簡単にまとめると、

安定性:0 - 非推奨
安定性:1 - 実験的
安定性:2 - 安定
(詳しくは ⇒ 公式ドキュメント)

使いたいAPIは、「require('下表のAPI名')」を書けば使えます。

API API名 安定性 説明
Assertion Testing assert 2 アサーションモードの使用
Async Hooks async_hooks 1 非同期リソースの追跡
Buffer buffer ※1 2 バイナリデータを一連のバイト形式に変換
C++ Addons ./build/Release/addon ※2 2 C++で記載されたアドオンをロード
Child Processes child_process 2 子プロセスを生成
Cluster cluster 2 サーバポートを共有する子プロセスを生成
Console console ※1 2 単純なデバッグコンソール
Crypto crypto 2 暗号化機能
DNS dns 2 名前解決
Domain domain 0 複数の異なるIO操作の処理
Events events 2 イベント処理
File System fs 2 ファイルシステム操作
HTTP http 2 HTTPインタフェース
HTTP/2 http2 2 HTTP/2プロトコルの実装
HTTPS https 2 httpsインタフェース
Inspector inspector 1 V8インスペクターとの対話
Net net 2 TCPまたはIPCサーバ・クライアントの作成
OS os 2 OS関連のユースティリティ
Path path 2 ファイルおよびディレクトリパス操作
Process process ※1 記載なし プロセス制御
Punycode punycode 0 Punycodeモジュールのバンドル
Query Strings querystring 2 食える文字列の解析及びフォーマット
Readline readline 2 ストリームから1行ずつデータ読み取り
REPL repl 2 REPLの実装
Stream stream 2 ストリーミングデータの操作
String Decoder string_decoder 2 文字列にデコード
Timers timers ※1 2 スケジュール操作
TLS/SSL tls 2 TLSおよびSSLプロトコルの実装
Trace Events trace_events 1 トレースイベント操作
TTY tty ※3 2 tty操作
UDP/Datagram dgram 2 UDPデータグラムソケットの実装
URL url 2 URL解決および解析
Utilities util 2 APIのユースティリティ
V8 v8 記載なし V8固有のAPI
VM vm 2 V8仮想マシン
Worker Threads worker_threads 2 スレッド作成
Zlib zlib 2 圧縮機能

※1. グローバルスコープ内にあるため、requireを使用する必要はほとんどない
※2. build/Release/addon.nodeという名前でアドオンが作成
※3. ほとんどの場合、直接操作する必要はない

さいごに

調べてみると、使っていないAPIがたくさんあることに気づきました。
フレームワークもたくさんあるのでそこら辺も一回整理したいですね。

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

HTML5製のゲームにSteamSDKを導入する手順(NW.js + Greenworks)

Steamに出すためのビルドをつくる流れを説明します。
Steamへの登録や申請などについてはこの記事では触れません。

登場人物

name desc
NW.js Chromiumを内蔵してWebアプリをネイティブ化するフレームワークです。Electronと同じ類のものです。
SteamWorks SDK 実績機能といった各Steamの機能を使うためのSDKです。それら機能を使わないとしてもSteamへのリリースには導入必須です。
Greenworks 上記Steamworks SDKをNode.jsで動かすためのパッケージです。

NW.jsでネイティブ化

https://nwjs.io/

公式サイトから目当てのOSのファイル一式をダウンロードします。

SDK版でないと検証ツールがつかないので、開発時はそちらがいいと思います。

各OS向けにリリースする場合、各OS向けのNW.jsを用意して構築する必要があります。

ダウンロードしたら、

  • その直下がpublicディレクトリとなるイメージで、エントリー画面となるindex.htmlや必要なjscssなどを置きます。
  • 同じく直下にpackage.jsonを置きます。

例えばこんな感じです↓

- NW.js本体
  - 元々あるファイルたち
  - package.json
  - index.html
  - js
    - index.js

ただし、mac版だと /nwjs.app/Contents/Resources/app.nw/* 配下に置くようです。

package.jsonのマニュアルはこちら
https://nwjs.readthedocs.io/en/latest/References/Manifest%20Format/

最小だとこんな感じ↓

{
  "name": "helloworld",
  "main": "index.html"
}

ちなみに、ゲーム自体の資材(jsや画像など)はnwのディレクトリに含めてもいいし、自分のサーバーでホスティングしてそこへ取りに行かせてもいいです。

サーバーに置けば、ネイティブ側のアップデートなしでゲームの不具合修正などができますね。

起動

起動するには、OSによりますが直下にあるnwnw.exeという実行ファイルを実行してください。

Ubuntuではこのnwが何故か実行ファイルとして認識されなかったので、*.desktopファイルを設置するようにしました。

[Desktop Entry]
Name=AppName
Exec=sh -c "$(dirname %k)/nw"
Terminal=false
Type=Application

自動化

ここまで行った手順は、 nw-builder というパッケージで自動化できます。

GreenworksでSteam対応

Greenworks https://github.com/greenheartgames

ゴール:

  1. 必要なファイルを準備する
  2. さっきのNW.jsのプロジェクト内に配置する
  3. アプリ(index.html)からGreenworksを使えるようになる
- さっきのNW.js
    - さっきのファイルたち
    - lib
        - libsdkencryptedappticket.拡張子 // ① Steamworks SDK
        - libsteam_api.拡張子 // ② Steamworks SDK
        - greenworks-OS名.node // ③ Greenworks本体
    - greenworks.js // ④ Greenworksのセットアップスクリプト
    - steam_appid.txt // ⑤ 開発時に仮でゲームIDを定義するファイル

①②はSteamworksから入手できる。
③はビルド生成物となり、Greenworksのリリースから入手できる。
④はGreenworksのリポジトリから入手できる。
⑤は開発用で、Steamworksのドキュメントに説明があります

しかし問題が一つあり、GreenworksのGithubで公開されている③のビルドは、最新のNode.jsに対応していません。

なので、最新のNW.js(=最新のChromium+最新のNode.js)で作る場合、Greenworksをクローンして自分でビルドしなければいけません。

古いNW.jsで構わない場合:

Greenworksでビルドをダウンロードする際、対応しているNW.jsが示されているので、それをNW.jsのアーカイブから取得します。

配布されているビルドをゴールで示したように配置します。(参考

Steamworks SDKはSteamworksからダウンロードしてください。

最新のNW.jsでつくる場合(自分でビルドする):

  1. Greenworksをクローン
  2. Steamworks SDKのディレクトリをまるごとsteamworks_sdkにリネームしてdepsに配置(参考
  3. nw-gypを使って再ビルド(参考

再ビルド手順

$ sudo npm install -g nw nw-gyp
$ cd Greenworksのディレクトリ
# targetはNW.jsのバージョン archは64bit
$ nw-gyp configure --target=0.46.1 --arch=x64
# targetはNode.jsのバージョン
$ nw-gyp build --target=v14.4.0

再ビルドに成功すると、直下のlibgreenworks-OS名.nodeを含む必要なファイルができるので、
それをさっきのゴールのように配置する。

ビルドだとかの知識が皆無なのでよくわからないんですが、多分各OSでこの作業が必要です。

OSごとに色んな理由でうまくいかなかったりすると思いますが、それは一つずつ調査して解決しましょう…。

無事にビルドできたら、ゴールで示したように配置します。(参考

Steamworks SDKを使ってみる

greenworksのビルドとSteamSDKを配置したら、エントリーポイント内から以下のようにgreenworksを読み込めるようになります。

const greenworks = require('./greenworks.js')
greenworks.init()

最初steamが起動していない 的なエラーが出てなんのこっちゃと思いましたが、
本家Steamアプリが起動していないといけないようです。

問題なく読み込めたら、Greenworksのドキュメントを参考に目的の機能を使ってみましょう。

Steam overlayが動かない

Steamオーバーレイとは、ゲーム内でSteamの機能(フレンドリストを開いたり)にアクセスできるものです。

僕はどうやってもこれを動作させることができませんでした。

Steamに審査に出した際、指摘事項に挙げられましたが、リリースのための必須事項ではありませんでした。

おわりに

NW.jsもElectronもそうですが、Chromiumを丸ごと含むので容量が大きくなることが難点ですね。

ともあれ、Greenworksという素晴らしいラッパーライブラリには心より感謝します。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue 3で`Extraneous non-emits event listeners...`警告を回避するための方法

再現方法

親子関係のcomponentsにおいて、子から親にemitすること

警告の内容

[Vue warn]: Extraneous non-emits event listeners (rewrite) were passed to component but could not be automatically inherited because component renders fragment or text root nodes.
If the listener is intended to be a component custom event listener only, declare it using the "emits" option. at ...

コンポーネントがフラグメントまたはテキストのルートノードをレンダリングするため、外部の非エミッツイベントリスナー(書き換え)がコンポーネントに渡されましたが、自動的に継承することができませんでした。
リスナーがコンポーネントのカスタムイベントリスナーのみを意図している場合は、"emits "オプションを使用して宣言してください。
[翻訳] DeepL

結論

子(emitする側)のコンポーネントでemitsオプションを定義した上で、emit名を配列で宣言する。
以下のESLintルールに詳しい。
https://eslint.vuejs.org/rules/require-explicit-emits.html
このルールが追加された背景としてはコンポーネントからどんなイベントがemitされるのかを構造的に宣言できるようにすることでコードの自己文書化をしよう!みたいな意図だと理解した。

詳細なコード

親で定義したmsg変数を子のinputタグで書き換える単純なprops down, emits upの構成

親コンポーネント

<template>
  <img alt="Vue logo" src="./assets/logo.png" />
  <HelloWorld :msg="msg" @rewrite="changeMsg" />
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'
import HelloWorld from './components/HelloWorld.vue'

export default defineComponent({
  name: 'App',
  components: {
    HelloWorld,
  },
  setup() {
    let msg = ref('Hello Vue 3.0 + Vite')
    const changeMsg = (e) => {
      msg.value = e.target.value
    }
    return {
      msg,
      changeMsg,
    }
  },
})
</script>

子コンポーネント

<template>
  <h1>{{ msg }}</h1>
  <button @click="count++">count is: {{ count }}</button>
  <p>
    Edit <code>components/HelloWorld.vue</code> to test hot module replacement.
  </p>
  <input type="text" :value="msg" @input="changeMsg" />
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'
type Props = {
  msg: string
}
export default defineComponent({
  name: 'HelloWorld',
  props: {
    msg: {
      type: String,
      default: 'default Value',
    },
  },
  emits: ['rewrite'], // このオプションが必要
  setup(props: Props, { emit }) {
    const count = ref(0)
    const changeMsg = (e) => {
      emit('rewrite', e)
    }
    return {
      count,
      changeMsg,
    }
  },
})
</script>

何が詰まったか

  1. emitsオプションを定義すべきことは分かるが、どういった形式で宣言すべきか、また何を宣言すべきかのヒントが無い
  2. 警告内容でググっても現時点ではこのルールそのもののPRしかヒットせず、どうすれば解決するかが掴めない(https://github.com/vuejs/vue-next/issues/1001)
  3. 純粋にググラビリティが低く、vue 3 emits option等検索しても2.x時代のドキュメントが大量にヒットしてしまう

というわけで

謎に詰まってしまった。。。
おもむろに親子間でemitするだけで警告されるので、2.x時代からVueを書いている方はお気をつけください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue 3から素のemitが警告されるようになったので対処する

再現方法

親子関係のcomponentsにおいて、子から親にemitすること

警告の内容

[Vue warn]: Extraneous non-emits event listeners (rewrite) were passed to component but could not be automatically inherited because component renders fragment or text root nodes.
If the listener is intended to be a component custom event listener only, declare it using the "emits" option. at ...

コンポーネントがフラグメントまたはテキストのルートノードをレンダリングするため、外部の非エミッツイベントリスナー(書き換え)がコンポーネントに渡されましたが、自動的に継承することができませんでした。
リスナーがコンポーネントのカスタムイベントリスナーのみを意図している場合は、"emits "オプションを使用して宣言してください。
[翻訳] DeepL

結論

子(emitする側)のコンポーネントでemitsオプションを定義した上で、emit名を配列で宣言する。
以下のESLintルールに詳しい。
https://eslint.vuejs.org/rules/require-explicit-emits.html
このルールが追加された背景としてはコンポーネントからどんなイベントがemitされるのかを構造的に宣言できるようにすることでコードの自己文書化をしよう!みたいな意図だと理解した。

詳細なコード

親で定義したmsg変数を子のinputタグで書き換える単純なprops down, emits upの構成

親コンポーネント

<template>
  <img alt="Vue logo" src="./assets/logo.png" />
  <HelloWorld :msg="msg" @rewrite="changeMsg" />
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'
import HelloWorld from './components/HelloWorld.vue'

export default defineComponent({
  name: 'App',
  components: {
    HelloWorld,
  },
  setup() {
    let msg = ref('Hello Vue 3.0 + Vite')
    const changeMsg = (e) => {
      msg.value = e.target.value
    }
    return {
      msg,
      changeMsg,
    }
  },
})
</script>

子コンポーネント

<template>
  <h1>{{ msg }}</h1>
  <button @click="count++">count is: {{ count }}</button>
  <p>
    Edit <code>components/HelloWorld.vue</code> to test hot module replacement.
  </p>
  <input type="text" :value="msg" @input="changeMsg" />
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'
type Props = {
  msg: string
}
export default defineComponent({
  name: 'HelloWorld',
  props: {
    msg: {
      type: String,
      default: 'default Value',
    },
  },
  emits: ['rewrite'], // このオプションが必要
  setup(props: Props, { emit }) {
    const count = ref(0)
    const changeMsg = (e) => {
      emit('rewrite', e)
    }
    return {
      count,
      changeMsg,
    }
  },
})
</script>

何が詰まったか

  1. emitsオプションを定義すべきことは分かるが、どういった形式で宣言すべきか、また何を宣言すべきかのヒントが無い
  2. 警告内容でググっても現時点ではこのルールそのもののPRしかヒットせず、どうすれば解決するかが掴めない(https://github.com/vuejs/vue-next/issues/1001)
  3. 純粋にググラビリティが低く、vue 3 emits option等検索しても2.x時代のドキュメントが大量にヒットしてしまう

というわけで

謎に詰まってしまった。。。
おもむろに親子間でemitするだけで警告されるので、2.x時代からVueを書いている方はお気をつけください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

DDD特別夏季講習 / 1時限目:値オブジェクトとエンティティ

はじめに

今、多くの開発手法が存在している中でひときわ耳にするのが「ドメイン駆動開発」、通称DDDです。
このシリーズでは、「ドメイン駆動開発」とは一体どういったものであるか、実際に職場で使っている人、導入を検討している人など、より多くの開発者に向けてわかりやすく説明していきたいと思います。
このシリーズを読破すれば、「ドメイン駆動開発」の全容をざっくり理解できるでしょう。

記念すべき第一回のテーマは「値オブジェクトとエンティティ」です。

対象読者

  • 職場でドメイン駆動開発を使って開発を行っている方
  • これからドメイン駆動開発の導入を検討している方
  • エリック・エバンスの本分厚いよー、ぴえん?って方
  • その他、ドメイン駆動開発ってなにー??って方

ドメイン駆動開発とは

今回のテーマである「値オブジェクトとエンティティ」についての説明を行う前に、そもそもドメイン駆動開発がどんなものか、その概要と目的を簡単に解説します。まあ、それは大体わかってるよーって方は飛ばしてもらって構いません。

ドメイン駆動開発は2003年にエリック・エバンスが提唱したソフトウェア開発技法です。
ドメイン駆動開発の特徴は、顧客と開発者の間で共通言語となる「ユビキタス言語」を共有して、その中で重要な立ち位置をしめる概念(ex. ショッピングサイトにおける「ユーザ」とか「商品」、「カート」とか)をそれぞれドメインモデルに落とし込み、そこからドメインオブジェクトの実装に落とし込む、という点です。大きくやることとしては
「ドメインの概念」 -> 「ドメインモデル」 と 「ドメインモデル」 -> 「ドメインオブジェクト」の2つですね。それぞれ簡単に説明します。

「ドメインの概念」 -> 「ドメインモデル」

ここではドメインの概念を明確化した後に、アプリケーションに必要な知識だけをよりすぐります。と言ってもなにも難しいことではありません。
例えば、書籍通販サイトがあったとして、ここに登場するドメインには当然、「書籍」があります。しかし、「書籍」というものがもつ全ての情報がアプリケーションに必要な訳ではありません。「著者」や「出版社」、「値段」なんかはアプリケーションには必要そうですね。ですが、「使用紙」と言った、書籍の材質に関する情報は必要でしょうか?通常の書籍通販サイトにはおそらく不必要なものでしょう(まあ、100%必要ないとは言い切れませんが笑)。
このように、開発しようとしているアプリケーションに必要な知識だけを、ピックアップしていきます。

「ドメインモデル」 -> 「ドメインオブジェクト」

アプリケーションに必要なドメインとその属性が固まったら、次はそれを実装できるオブジェクトの単位にまで落とし込んでいきます。
この、ドメインモデルをドメインオブジェクトにまで落とし込む段階で登場するのが、この記事のテーマである、「値オブジェクトとエンティティ」です。
まあ要するにこの二つは、実際に設計から実装に移っていこうか、って時に対面するものな訳ですね。

ドメインモデルとして列挙される概念は、大まかこのどちらかに当てはまります。
ですが、この区別(ドメインモデルが値オブジェクトかエンティティなのか)の方法について、わかりやすく説明してる記事がまあ少ない。まじでなに言ってるかわかんないモノが多すぎる。ので世界一わかりやすく説明したいと思います。

値オブジェクトとエンティティ

値オブジェクト(別名:バリューオブジェクト)は、その名の通り「値」をオブジェクトとしたものです。

値オブジェクトには以下の特徴があります。
1. 不変である
2. 交換が可能
3. 等価性によって評価される
4. ライフサイクルを持たない

一方エンティティは値オブジェクトとは対をなすオブジェクトです。特徴は以下。
1. 可変である
2. 同じ属性であっても区別される
3. 同一性によって評価される
4. ライフサイクルを持つ

この中で、はじめの段階での判断に特に使えそうなのは3あたりでしょうかねぇ。

等価性と同一性による区別

等価性と同一性による区別において、便利なのは「そのオブジェクトの属性値が変わった場合、そのオブジェクトは変更の前後で別物とみなされるか」、「値が同一の場合、それらは同じものとみなされるか」という問です。その答えがYesならそのオブジェクトはエンティティです。

ここではショッピングサイトにおける、「ユーザ」というオブジェクトから考えます。
ユーザはいろいろな属性を持ちます。例えば、名前とか、住所とか、年齢とか。後、サービス上の識別子(userId)なんかも付与されるでしょう。これらのオブジェクトは、さて、一体どちらでしょうか。

user: {
 name: "ruirui_official",
  address: "虎ノ門ヒルズ最上階",
  age: 22,
  userId: 104
}

まず、user自体について考えましょう。ユーザのるいさんは人間です。るいさんの属性値(ここでいう、ユーザー名とか、住所とか)がもし仮に変更されたとしましょう。るいさんは"ruirui_official"ってユーザ名、有名人気取りでダサくね?って気付きます。そして"rui"に変更します。加えて、最近六本木ヒルズの最上階に引越しをしたので、住所も変更しておきます。

user: {
 name: "rui",
  address: "六本木ヒルズ最上階",
  age: 22,
  userId: 104
}

情報が変更されました。さて、ここで「そのオブジェクトの属性値が変わった場合、そのオブジェクトは変更の前後で別物とみなされるか」と問います。
もちろん答えはNoですね。サービスの登録名を変えようが、住む場所を変えようが、るいさんは同じるいさんです。
るいさん、すなわちユーザというオブジェクトはここではエンティティとなります。

次にuserIdというオブジェクトに着目してみます。uesrIdはアプリケーションに登録したユーザ全員に付与される固有の識別の値です。
ここでも同じ問いをします。すなわち、「そのオブジェクトの属性値が変わった場合、そのオブジェクトは変更の前後で別物とみなされるか」です。
userIdは各ユーザに割り振られるユニークな値です。もしこれが変更された場合、そのuserIdは同じものとしてみなされるでしょうか。
104のuserIdと105のuserIdは同一なものではありません。uesrIdとして完全に別物です。そして、104のuserIdと104のuserIdは同じものとしてみなされます。
つまり、userIdは値オブジェクトと言えます。

注意点

上記のやり方で、多くのオブジェクトがどちらに属しうるのかを判断できます。
しかし上記のやり方であるオブジェクトが値オブジェクトなのか、エンティティなのかを判断する時注意しなければならない点があります。
オブジェクトが「そのオブジェクトの属性値が変わった場合、そのオブジェクトは変更の前後で別物とみなされるか」、「値が同一の場合、それらは同じものとみなされるか」を考える時、なににとって別物とみなされるのか、もしくはなににとって同じものとしてみなされるのか、ということです。
私たちが今物事を考えている世界は、あくまでもアプリケーションの世界です。なので、そのアプリケーションにとって別物とみなされるか、同じとみなされるか、という視点で考えなければなりません。

分けた後の扱い

じゃあ、ちゃんとオブジェクトを分けました。分けたまではいいのだけれど、何のために分けたの?どう区別して扱うの?って疑問が浮上するでしょう。
それは値オブジェクトとエンティティの性質として上であげた4つのうち1,2,4あたりが使えます。

値オブジェクトはその名の通り、「値」です。皆さんは値を変更する時、どのように変更するでしょうか。

const age = 22
age = 23
console.log(age) // 23

こんな感じですかねえ。値を代入しています。
これは値オブジェクトの2の特徴である、「交換が可能である」ということの意味するところです。
値は交換して変更します。

これはエンティティの値の変更方法とは少し異なります。

user.changeName("rui")

これを値オブジェクトでやっては絶対にいけません。
値オブジェクトはあくまでも「値」です。つまりただのプリミティブな値、"mojiretu"だとか100だとかの単なる値と同じです。

100.changeValue(101)

とした場合、どうなるでしょう。100は101に変更されます、、、、コードの全てにおいて。
すなわち、const a = 100は実際のaは100ではなく101になっています。なかなかにカオスですね。

これが値オブジェクトの1番目の特徴である、「不変である」の意味するところです。

終わりに

いかがだったでしょうか?ドメイン駆動開発を学ぶにあたって、まずはじめに出てくるのがこの「値オブジェクトとエンティティ」の概念です。
実装できる単位のドメインオブジェクトの代表格とも言えるこれらの二つをマスターすることは、DDD理解の第一歩です。
ですがドメインオブジェクトとしてあげられるのはこの二つだけではありません。
次の講義では、この二つ以外のものについて簡単に説明したいと思います。お楽しみに。

参考

「ドメイン駆動設計入門」 著:成瀬允宣

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

3Dブロック作成ツールを作ってみる【Three.js】

3Dのブロック作成ツールを作成してみました。

プログラムの動き

See the Pen Cube-20200701 by Naru0607 (@naru0607) on CodePen.

コード

const boxes = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1];
const size = 0.5;
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer({
  antialias: true
});
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 100);
const light = new THREE.DirectionalLight(0xFFFFFF);
const geometry = new THREE.BoxGeometry(size, size, size);
let material = new THREE.MeshLambertMaterial({
  color: 0xFF0000
});
const interval = 100;
const group = new THREE.Group();

camera.position.set(size / 2, 0, Math.cbrt(boxes.length) * 4);
light.position.set(0, 100, 100);
scene.add(light);

for (let i = 0; i < boxes.length; ++i) {
  ((i) => {
    if (boxes[i] === 0) {
      return;
    }

    const cube = new THREE.Mesh(geometry, material);
    const pos = getPosition(i);

    cube.position.set(
      pos.x * size,
      pos.y * size,
      pos.z * size
    );

    setTimeout(() => {
      group.add(cube);
    }, i * interval);
  })(i);
}

scene.add(group);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

render();

function getPosition(i) {
  const length = boxes.length;

  const x = i % Math.cbrt(length);
  const y = Math.floor(i / Math.pow(Math.cbrt(length), 2));
  const z = Math.floor(i / Math.cbrt(length)) % Math.cbrt(length);

  return {
  x: x,
  y: y,
  z: z
};
}

function render() {
   group.rotation.x += .01;
   group.rotation.y += .01;
   group.rotation.z += .01;
  renderer.render(scene, camera);
  requestAnimationFrame(render);
}

コードの解説

以下のように、Boxを置くか置かないかを"0","1"で保持しています。

const boxes = [1, 0, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1];

配列に格納されている値を順に処理して"0"の場合には何もせずにループを抜けて、
"1"の場合にはBOXを置いています。

for (let i = 0; i < boxes.length; ++i) {
  ((i) => {
    if (boxes[i] === 0) {
      return;
    }

    const cube = new THREE.Mesh(geometry, material);
    const pos = getPosition(i);

    cube.position.set(
      pos.x * size,
      pos.y * size,
      pos.z * size
    );

どの順番でBOXを配置していくか

x軸、Y軸、Z軸を以下のようなイメージで定義しました。

image.png

次に配列要素iの値に応じてどの順番でBOXを配置していくかを決定します。
今回はBOX配置順を次のように決めています。(コードでは3×3×3ですが、2×2×2で例をあげています)

image.png
image.png

getPosition関数のなかで配列要素iの値に応じてどこにBOXを配置していくかを決めているのですが、この際に重要なのは配列数と配列要素iの値の2つの情報から上記テーブルのX,Y,Z座標のそれぞれの値を表現することです。こうすることで2×2×2や3×3×3以外にも、6×6×6というようにボックス数を増やしていった場合でも対応ができるようになります。この点が難しいのですが、Excelを使用して以下に具体例を記載しています。

マトリックスの数

マトリックスの数は、配列数の立方根(∛)で求めることができます。
例えば、
配列数が8個の場合には、2×2×2なので、2
配列数が27個の場合には、3×3×3なので、3
となります。

X軸、Y軸、Z軸

X軸、Y軸、Z軸を順に説明します。

X軸

Xは配列要素iの値をマトリックス数で割ったときの余りで求めることができます。
image.png
※ExcelのMOD関数(数値,除数)は、数値を除数で割ったときの余りを返します。

該当コード

const x = i % Math.cbrt(length);

Y軸

Yは配列要素iの値をマトリックス数を2倍した値で割ったときの整数部で求めることができます。
image.png
※ExcelのQUOTIENT関数(数値,除数)は、数値を除数で割ったときの整数部を返します。余り部分(小数部) は切り捨てられます。

該当コード

const y = Math.floor(i / Math.pow(Math.cbrt(length), 2));

Z軸

Zは配列要素iの値をマトリックス数で割って求めた小数点以下切捨ての値にさらに、マトリックス数で割ったときの余りで求めることができます。
※Excelの関数では、小数点以下切捨てを求める際にはROUNDDOWN関数を使用します
image.png

該当コード

const z = Math.floor(i / Math.cbrt(length)) % Math.cbrt(length);

最終的には次のようになります。

function getPosition(i) {
  const length = boxes.length;

  const x = i % Math.cbrt(length);
  const y = Math.floor(i / Math.pow(Math.cbrt(length), 2));
  const z = Math.floor(i / Math.cbrt(length)) % Math.cbrt(length);

  return {
  x: x,
  y: y,
  z: z
};
}

プログラムの動き(回転アニメーションをOFF)

[rerun]ボタンをクリックしてください。


See the Pen
Cube-20200701-1
by Naru0607 (@naru0607)
on CodePen.


BOXに色付けをする

先ほどの例では、配列名Boxesには"0","1"のいずれかを保持していましたが、
少し発展させて配列の要素に、"red","yellow","green","blue"のように色情報を持たせた場合の動きです。これで任意の色でブロックの配置ができるようになりました。

プログラムの動き

[rerun]ボタンをクリックしてください。


See the Pen
Cube-20200705-2
by Naru0607 (@naru0607)
on CodePen.


  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

10年日语营业转行IT从深圳到日本东京圈工作生活2019

10年日语营业转行IT从深圳到日本东京圈工作生活2019

主旨:成人学历可以来日本,拖家带口可以来日本,不通过中介可以来日本,只会日语不会IT可以来日本。

序章

日本对于我来说确实曾经像一个童话,

日本对于我来说确实曾经是高不可攀的目标,

日本对于我来说确实曾经是一个做醒了就扔到一边的梦。

2011年,日语语言学校毕业取得日语一级,刚毕业就想过去日本,当时说是要10万元保证金,全日制本科学历,还有留学学费,这种事情咱想想就蒜了吧。由于贫穷限制想象,一直在东莞的各大人才市场大显身手,进了各种坑货台湾工厂。

2014年,真正进了一家传统日企,真正做上了业务,真正锻炼到了终身难忘的日语,真正成家,真正成人,这家日企经历了两三年。

2018年,深圳地王大厦之上,加班到半夜,深圳夜景万家灯火,曾经的日语同学一通电话问哥们你还想去日本试一下吗?这通电话点燃了多年的梦想,星星之火迅速燎原。

到日本可以留学,也可以工作,留学的话前期费用和打工的辛苦程度很高,选择工作到日本挺稳妥的。有钱人可以投资移民到日本,不过终究不是穷人考虑的问题,其实调查下来,开公司的最低注册金500万日元有了这个钱就挺好办的。

有啥条件

2009年-2011年,国内日语培训学校毕业,无学历。取得日语二级,日语一级证书,J.TEST准B级证书。

2012年-2014年,自考外语系的大学,取得自考大专学历。

2017年-2019年,取得成人本科学历,专业国际经济与贸易。

2019年6月-9月在大连,开始学习Java相关前后端知识。HTML , CSS , JS , Vue.js , Node.js , Java , PHP , Laravel 。后面陆续学了Linux,SSH框架,SSM框架,微服务,Jenkins自动化等,基本自学。

应聘过程

【2018年5月:】

  1. 这时候完全没有想到以IT工作去日本这个点子,只想着要去日本,不管什么手段。
  2. 开始着手制作日文简历,再做一个中午简历副本,更新前程无忧上的中文简历。
  3. 在百度找了一些日企招聘网站,也联系了一些专业面向日企的猎头。
  4. 在日本的DODA和リクナビNEXT投简历,收到skype面试唐吉坷德的店员机会,面试失败。
  5. 最后找来找去还是觉得去不了日本,就在去IBM的日语电话客服或者日企上市公司的商社做决定,还是继续在国内又工作。

【2019年4月:】

  1. 边工作,还是继续寻找赴日工作的机会。
  2. 利用日本的DODA和リクナビNEXT,以及各种日本的猎头,PASONA,Recruit Agent等制作日式的 履歴書職務経歴書
  3. 尝试利用日本求职网站寻找合适的公司,通过DODA找到了一家日本仙台的公司做日语营业经常出差日本的工作,面试失败。
  4. 想尝试通过各种中介去日本的温泉酒店,电信店的工作等,中介费我还是介意了,放弃。
  5. 4月17日晚上,进行第一家IT公司的视频面试,这家公司就是2018年5月份找工作的时候前程无忧上联系到的,因为找赴日工作各种碰壁,所以当他晚上坐在肯德基不知道怎么突发奇想翻到微信里的这个联系人,一联系结果妥了。现在想来还是微信联系人管理的细致,才找到了这么个机会。
  6. 因为上次聊天就问了会日语不会编程怎么办,她说可以去她大连分社学习,所以入行也有了着落。
  7. 面试的是在日本那边公司的法人、社长,永驻资格的中国人。自我介绍以及一些针对简历上个人经历背景的普通问答,日语交流,基本没有涉及到技术问题,后面聊薪酬待遇、日本工作生活的情况等等,面试过程很轻松顺畅,没有很形式的感觉。因为本来就是感觉要么会日语,要么会编程都可以进的一个公司。
  8. 因为我是自考大专学历,她说需要确认这个学历能不能办工作签证,后面确认到这个学历办理企业内转勤签证比较好。
  9. 开始调查这家日本有总社,大连有分社的公司,看有无猫腻
  10. 4月22号坐火车去大连考察这家公司真假,反正去了一天也没调查出多少东西来。结果最靠谱的回答是我有个朋友在香港上班,因为他在日本做过IT行业,所以懂;说是这种公司也不存在坑不坑的,就是中间商赚差价。大连有很多这样的,所以也就放心了。
  11. 犹豫自己能不能学会编程

【2019年5月~~:】

  1. 5月8日--5月14日,毕业证、日语等级证、淘宝买的印章,二寸照片等其它材料打包国际EMS邮寄到日本公司,由日本公司方面申请在留资格。
  • 各位,这里注意了,办签证这些不需要毕业证、学位证、日语等级证什么的原件、全部复印件就搞定了。
  • 但是公司要想押你的,你又想去,那就答应吧。
  • 因为我后面,学历和日语等级证都去相关机构申请补办回来了,也算是见招拆招了,他不还给我也无所谓了。
  • 这里批评一下不知哪些人在网上留言说日语等级证书不能重新补办,我真是信了他的话了,还真准备不申请补办去直接再考一次日语一级。
  • 可最后还好尝试了一下在日本补办结果补回来了,美美哒。不过学位证咋补办就不知道了。
  1. 6月1号坐长途火车去大连学编程。

  2. 6月3号到大连入住大连弘基书香园小区,宿舍二三十个人,上下铺铁板床,基本都是各个公司搞IT的程序猿,房租420一个月水电网都包了,刚开始住进去的稀稀拉拉几个人,住到后面不知道是毕业季的原因还是啥,一下子宿舍住满了人,而且还有个哥们天天早上洗澡半天不出来,哈哈 。二三十个人的宿舍就一个厕所,其他都还好,其中有段时间我一直就在附近的星巴克蹭空调,从来没有买过一杯星巴克,罪过,罪过!想起这几个月的居住经历,我到了大连的夏天连小风扇都没舍得买过,真是节约到底了,确实像是艰苦奋斗的感觉,每天学习也是拼了老命想要把编程学会,最开始2进制也不知道是什么,我就是一个10万个为什么这样学过来的,其中的心酸和激动就真是难以言表了,,里面的程序猿很多都喜欢打游戏,我住了四个月也没交到什么朋友,可能是因为我不是很喜欢打游戏吧。最后走的时候,房东还是如数退还了押金,这算是比较良心了。

  3. 过了一个星期左右和大连分公司这边签订了劳动合同,这里是中国法律管辖内的合同,基本就是制约你在分公司呆了3个月左右一定要去日本的,不去要赔偿培训费、签证办理费这样的条款。

  4. 6月26号---7月26号办理大连的居住证,因为住的便宜的房子,办居住证花了功夫,这里感谢公司的同事!

  5. 8月28日,收到日本总社人事通知,在留资格认定证明书下来了。

  6. 9月5日, 在留資格認定証明書 通过EMS邮寄到手,在留资格类型 企业内转勤 1 year

  7. 看到在留资格认定书在8月2号都已经许可了,感觉公司给我押了半个月,谁曾想当时那段时间真的好郁闷,好颓废,好焦急;当一个东西等不到的时候,我就去查平均下在留的时间,在留认定不通过的理由原因等,我真的是费尽心思想为啥要那么久才下在留,因为,我是5月8号就寄了资料,6月11号日本总社人事问我工作经历,提交在留申请,估计最晚提交在留申请也就是6月15号左右,可是到了8月15号,等了2个月,我还没等到在留下发的消息,心里真是焦急啊。

  8. 9月5日,前往大连国际友好服务中心申请代办 签证,需要材料:

①,签证申请表、

②,户口本原件和复印件、

③,在留资格原件和复印件、

④,大连暂住证原件和复印件、

⑤,护照原件的复印件、

⑥,白色的照片身份证及复印件,

具体咨询各辖区代办点(个人出境签证只能通过指定的代办机构办理,签证费200,代办费300不等,我一共花了400元左右)

  1. 9月16日,拿到签证。

  2. 9月24号,回家团建。

  3. 在深圳的中国银行深圳支行,取日元挺麻烦的,最好去大的中国银行取钱,大的支行。取钱之前最好预约一下。取出约25W日元现金作为头两个月生活费带过去(最多可携带约60W,5k美元等值)银行间汇率有差异,一般情况下取40W日元,最大相差600日元左右,在日本相当于一顿午饭钱,当时汇率,0.066632,汇率可参考:人民币专栏-外汇频道-和讯网

  4. 10月10日,出发去日本!!我买的南航直飞羽田机场的机票,提前一个月,机票便宜得很才958元,又是白天的飞机,坐起来很爽,但是第一次坐了4个多小时飞机,还确实感觉有些长。关于入境卡,填的资料零零碎碎的,建议提前做好准备,网上搜索样板,自己准备好要填的内容,在飞机上就不用手抓脚抓的。

  5. 来日之前需要准备的事情:
    ❶.购买手机上网wifi卡,推荐淘宝购买链接:http://zmnxbc.com/s/71iSV?tm=452a56(临时Wi-Fi卡,下飞机后保证微信能联系上),或者是买一张CMlink的电话卡,也很便宜的。
    ❷.如果来日后要给家人如妻子等办理签证,需要带上公证书,结婚证,妻子或其他家人的护照复印件,证件照来日。这样来日后方便申请
    ❸.行李问题,公司宿舍一人一个房间,但是被子行李需要自己提供,可以从国内带过来也可以来日后再自行购买
    ❹.西服套装、衬衫、皮鞋,这个至少准备一套
    ❺.登机前需要准备一支笔,飞机上填写入境卡需要
    ❻.手头换一些日元好方便生活使用

公司及工作形式介绍

1.应聘的公司是在神奈川县由中国人开办8年左右的小型IT外包企业,这公司还有关联的房地产公司,大连有分公司,约80名开发人员(技術者)。

2.入职公司,是IT方面毕业的,或者是有经验者,或者是懂点日语的基本都可以进。而这些东西即使你不会也是可以马上学习,马上报个培训班什么的,马上就可以进这种公司的,所以我的观点是只要是个人都可以进。

3.获得技术签证需要有相关资格,如大学专业学位类型、软考资格证、工作经验年限等;最好N3-N2水平以及2年以上工作经验等。

简单来说只要想去日本,这些都不是问题,即使你一无所有。

​ 我刚开始对于签证办理真是花费了大量调查研究工作:

  • 日语,日语好就是件好事,可是很遗憾,日语和办理签证没啥关系,除非高度人才签证可以加点分。

  • 公司规模,你赴日的这家公司,公司规模越大越好,当然最不济的注册金500万日元也不是不可以。

  • 工作经验,假如IT,最好是在国内也有相关IT工作经验,当然越长越好。

  • 学历,你要到日本来做什么事,最好学历上就是这个专业的。成人大专,自考学历,大专,本科学历等这总是最低要求了吧。

  • 如果来日本做IT,办理技术签证。国内的软考证明(软件考试,计算机,网络,通信方面的考试)特别有帮助,程序员考试以及网络工程师等等,在日本也是得到认可的,而且有了这个还可以免除学历审查的要件,简单说有软考证就可以不看学历了,这方面有不懂的可以问我。具体哪些证书日本承认可以了解一下:

1 (1).png

4.日本IT行业状况:

1 (2).png

  • 如图所示,SE是工资少,工作又不开心的一群人。要想钱多,工作又开心,你就要想办法进入日本真正的互联网行业,有些人也称WEB系的行业,因为和SIer相对应的就是WEB系。
  • SIer(Systems Integrator er),也就说系统整合商、er是拟人化表现手法。可以理解为软件外包系统承包商。
    • 上流的有野村,伊藤忠,NEC,富士通,NTT DATA,KDDI等等;他们是一级承包商。
    • 下面的二级承包商都是些独立系的软件承包商、又叫soft屋,这种一般都是日本人开的公司去承接这些大的SIer的活。
    • 再下面很多就是三级承包,一般都是公司实力小,但是有几个人能派出去的,很多中国人老板开的公司什么的。
    • 这种小派遣公司也会有很多人脉,交叉派遣什么的,假如我们都是小派遣,哪天我缺人了就找你问;你哪天缺人了就问我。
    • 很多IT赴日的小伙伴就是进的这种公司,就等于一包、二包、三包、N包的公司了,肯定项目预算被吃了一截又一截,留下残羹剩渣。
    • 这么底层的承包,交期非常紧迫,这就滋生了超级加班的土壤。
    • 所以想要在SIer行业混得好就要朝IT consultant也就是IT系统、软件顾问什么这方向发展才能赚大钱;接触最上流,工作内容都是客户咨询对应,要件定义,基本设计,技术选型这些内容。详细设计,编码,测试都是下流,所以苦逼又穷。所以,要做这些,那么也就意味着你的文档要做得很漂亮,Excel、Word、PowerPoint什么的溜溜溜。
    • SIer行业一般都不是敏捷开发,一般都是瀑布流式开发。但是我不知道其他SIer什么情况,我去的那个SIer现场就是敏捷开发了,而且用AWS这类的一些云服务,Jenkins自动化等,还是在技术上挺跟得上时代的。
    • SIer行业一般都是大项目,动辄几百上千人的项目。而且都是些大的国家基础设施已经银行,保险,政府业务居多。
  • 但是,大部分日本IT小公司都是以派遣企业形式生存;特别是中国人老板在日本开的公司,从中国、印度、越南、缅甸、日本等地招募大量IT开发人员,派驻到各个客户公司(称为现场)驻点上班,开发代码。
  • 到现场上班的机会需要自己另行跟现场负责人面试争取(日语为主),上班时间及每月基准工作时间依现场而定,午休1小时,请假由现场领导批准即可。
  • 项目结束后需要找下一家现场面试进驻新项目,一般这种SES的项目合同都是签3个月,现场的人觉得你做得好,你的合同可以无限期延长;如果合同不续签了,那么会提前一个月通知你的,以便于你去找下个现场;当然,找现场是你的公司回去给你安排的。
  • 自己所属的派遣公司仅负责招募员工、联系现场安排面试、发放工资等工作。

5.刚到日本一般住公司宿舍,有条件自己去找个UR团地住更好,开办银行账户、购买手机、日常用品等,西装我建议不要在淘宝去买,其 实在日本买西装一套很方便而且也不是很贵的,而且出门少带点东西不香吗,而且消费也是一种美德哈。

合同以及劳动条件

工作条件

1.来日本前在国内先和分公司那边签订劳动合同,3年的,如果国内培训期间(其实不培训多少知识,基本上自学或者免费给公司干活) 离职,或者在日本没有干满3年公司可以让你赔付培训费签证办理费用什么的,大概1万元左右。这个合同可以无视,因为个人感觉中国 和日本的法律不同,你只要到了日本,这个合同就不用去考虑它的约束力了。

2.在国内说好的是正社员,结果到了日本公司一来就签的是业务委托合同,也就是个人事业主。这时感觉特别无助,但是也只有忍了。日 本的一般的劳动合同形式分类:

​ 2.1.正社员

​ 2.2.契约社员

​ 2.3.派遣社员

​ 2.4.个人事业主

​ 一般来说,因为这种从国内招人到日本的公司都是小规模的SES公司,所以一般还拿不到派遣资质;然而,正社员招聘的各种成本高 昂,比如税务、保险等方面的花费很高;所以一般的中国人的老板从国内招聘IT技术员过来的劳动合同形式都是契约社员。

​ 殊不知,我这家公司连契约社员都省了,直接签个人事业主的合同,也就是业务委托合同,就等于说公司除了给你这点钱,其他啥都不 管了,简直是狗血至极。要知道,在日本不管是外国人还是日本人做个人事业主的,月收入基本都是50万日元左右打底的,我连人家一 半收入都不到,这不就是狗血至极吗?而且,最终去的现场也都是被派出去的形式,实质上是以派遣社员的方式在工作。

3.个人自费购买国民健康保险、国民年金、自己报税。

4.月薪约25-30+万,视工作经验而定,如工作满5年左右大概可以达到50W月薪。

5.年薪按12个月标准,次月月底发出。

6.全额报销交通费(仅限电车定期票,每月2万日元上限)。

7.无年终奖、无年假,有一点点旅游等福利。

8.不包食宿,大概一个月的花费10万日元左右。

9.没有被派驻到现场正式工作之前60%工资,每周双休+法定假日。

10.每天工作8小时,中午一般休息1小时,带加班费(基本不要指望)

11.每月基本工作时间,各公司各自规定一个属于正常的月工作总时长,例如160-200个小时,每月记录本人工作总时长低于下限值 则扣减相应工资,超出上限值部分则算入加班时间,工作时长在区间内按约定月薪给付,不多不少。反正,拿正常工资不多不少的时候占大多数。

12.每年有一次涨薪机会,最高也就涨个月薪多5万日元左右。

13.自己后面研究了一下,我这家公司抽成率(margin),也就是抽水,中间商赚差价。抽成率差不多40%--50%左右。

14.其实很多游戏规则都是中国人的圈子自己玩死了自己。我后来有面试一家日本的这样的IT派遣公司,

  • 首先人家抽成率会给你说好,比如30%,其实日本派遣业界差不多抽成率都只有20%--30%这个区间,高出去那可真是黑心!
  • 和现场签订的合同会给你看,那么你就知道合同单价,抽成率,项目期间之类的细节。
  • 公司和你不绑定,假如做完一个现场,下个现场最后那个月没找到,而且通过现在这家公司也没找到合适的,你可以同时进行其他公司的现场介绍以及面试,你就不用看着这一家公司脸色,在一根树子上吊死。这里,你是工作签就可以这样。企业内转勤签证不可以。

实际工作现场情况

NEC

大型SIer企业,刚来日本遇到的第一个现场第一个项目就是非常紧迫而恶心的保险方面的项目,这是个二包的项目,就是日本的一包公司接了NEC,我们公司接了这个一包公司的活,所以我们公司派出两个人是属于二包,每天加班到晚上8、9点是很正常的事,有几周经常加班到深夜终电,11点左右,真是恶心至极,上司是一包公司的日本人,性格古怪而且不吃早饭不吃中午饭,另外这团队里的日本人同事也是那种宅男型的二货,沟通交流感觉很费劲,喜欢划船,推卸责任,着实恶心了一把。

然后

不过,不管怎样感谢所以的一切成就了我的今天。然后就没有然后了,前面现场完了之后就一直待机,因为新冠肺炎,待在家根本就是40%工资了,待机2个月后面就不发钱了,简直恶心至极。而且因为是业务委托合同,労働基準監督署管不了这个事,找律师标的太小,律师都不敢兴趣。我就开始了转职活动。

40天面试了20次,和猎头面谈了30次,咨询换签证找了行政书士50家。现在等签证变更结果。希望努力到感动自己,拼搏到无能为力。

1 (3).png

感想

1.关于签证的结论:

  • 成人学历可以来日本,成人大专,成人本科,自考大专,自考本科都可以。
  • 但是,如果做IT的,学历上的专业最好是计算机方面的专业。
  • 办工作签困难的情况下,可以考虑企业内转勤,投资经营签证,留学签证,技能研修签证。如果都试了无果可以问我免费解答。
  • 投资经营签证最好准备50万元资金,最好不要投资房地产,而是去开一家公司。
  • 想先好好学下日语的伙伴,留学签证合适,不要信中介的,留学签证很好办,而且啥时候想到了就去着手,不用考虑一年只有两次机会什么的。留学期间可以打工,而且加入没有学历,留学期间再去考个国内的成人学历。
  • 办什么签证都好,找一个律师或者行政书士会事半功倍,前提是要自己信得过的。
  • 申请签证后的那些申请材料自己一定要保存好,你上面填写的工作经历这些,防止下次变更签证什么的不要写错。而且行政书士的好处是事无巨细的会把资料给你整理好,包括申请材料,申请后会告诉你申请的什么,用的哪些材料。这个就是花钱的好处。
  • 行政书士也好,律师也好,申请签证前让你准备资料,准备好资料后会签委托合同,你委托让他代办这件事情,而且事先开具收据给你要收多少钱。然后你再给钱他办事。反正就是你给资料他收钱后提交申请了,签证就成功了80%了,因为材料不对他不会接这活。签证不许可,他会去给你找出原因重新申请或者怎样,都是会帮你做最后努力的。唯一就是你材料造假什么的,那么被拒签他就管不了了。所有材料都没准备,收据也没开,委托合同没签字就让你交定金,或者给钱的都是不正当操作。
  • 办签证不放心可以先办个旅游签证过来日本找好你信任的行政书士,或者确认你要去的公司的真实性。题外话,就是日本有很多侦探事务所,可以调查很多事情,你觉得不好办的事情可以去探侦事务所试试。不会日语找翻译给钱也就搞定啦。
  • 找一家好的公司,签证啥都不用操心了。
  • 给中介费来日本的很多,存在即合理,只要给钱办事还好,给了钱不办事的就当交学费了。中介介绍工作赴日的基本都是人不愿意干的,到时候你就要花更多时间在换工作方面。但,哪有那么完美的呢?有时候想想能来就先来吧。

2.拖家带口可以来日本,两口子都上班交税,日本给小孩有补助,而且免学费。一家人在日本生活更好点。房租水电什么的都均摊了。家人办家族签证就好了,办之前先去国内做一下亲属公证省得后面麻烦。

3.不通过中介也可以来日本,上面也说了存在即合理。通过中介可以让你来日本的日程更具体。但是中介这些鱼龙混杂,而且大多数赚的钱很黑心,间接使得多数赴日小伙伴体验太差。因为做的工作都是很底层的,而且介绍的工作行业和薪资都是拖后腿型的,让你刚来日本真是苦不堪言。

4.只会日语不会IT可以来日本,我很推荐来日本做IT行业,不过现在哪个国家做IT行业都还可以。因为我个人做了这么多年日语业务经验来看,当初踏入IT行业的彷徨,以及进入IT行业后的感受到的好处来看。其实编程方面的入门什么的不难,而且IT行业收入稍微高一些,而且将来做Freelance,也就是自由职业者的吸引力来说,真是一个很推荐的行业。

最后

世上没有最好,只有最适合你!是金子哪里都会发光,但是最适合你发光的岂不更好。

致所有一脚踏入日语这个世界的小伙伴。做IT程序员,翻译,日语业务,品管,财务,工程师,采购等等。

相信这篇文章给了一些日语从业者想来日本的一条出路。

这篇文章肯定得罪很多人,中介,以及各种商家。现在想来,当初就是想空手套白狼,真是想来日本但是有没有任何资金准备,途径也是寥寥无几,凡是都想省着,凡是都想甄别不要被蒙。所以一条路走下来有很多感想,也经历了很多人没有经历的惨状。而且现在想来华南地区做了10来年日语业务,当初就不应该进制造业,活多钱少离家远,位低权轻责任重。一脚踏进IT行业,感觉真是360度大变样。

我的邮箱:1019354192@qq.com

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Vue/Nuxt/Express】Universal Modeでメールアドレスのログイン機能を実装した

NuxtのUniversal Modeで開発しているのですが、ログイン機能を作るのに悪戦苦闘しました。

備忘録がてら実装をまとめてみたいと思います。

NuxtがフロントエンドでRailsがバックエンドになりますね。

Railsの認証機能はdevise_token_authを利用しています。

https://github.com/lynndylanhurley/devise_token_auth

※devise_token_authについては詳しく触れないので上記URLを見ていただけると幸いです。

ログイン機能の流れ

前提として、メールアドレスとパスワードを用いたよくあるログイン機能を実装を実装しています。

まずは、ログインの流れを説明します。

  1. ログインページにアクセス
  2. メールアドレスとパスワードを入力してAPIにPOST
  3. APIからログイン認証のTokenが返却される
  4. Storeに返却されたTokenを保存
  5. StoreからTokenを取り出してExpressにPOST
  6. ExpressでPOSTされたデータを受け取ってCookieを生成
  7. Expressからフロントエンドに対して何か適当にレスポンス
  8. Cookieがブラウザに保存される
  9. SSR時にサイトにアクセスする
  10. nuxtServerInitでCookieのデータを取得
  11. 取得したデータをVuexに保存する

この流れでログイン認証をしていると言う感じですね。

Nuxt.jsのUniversal Modeでの実装

サインインページ

methodsのみ抜粋

<script>
export default {
  methods: {
    handleInput(name, value) {
      this[name] = value
    },
    async handleSubmit() {
      const body = {
        email: this.email,
        password: this.password,
      }

      await this.signIn(body)

      await this.setTokenInCookie() // StoreのTokenをExpressにPOSTするactionsを呼び出す

      alert('ログインが完了しました')

      this.$router.push('/')
    },
    ...mapActions('user', ['signIn']),
    ...mapActions('user', ['setTokenInCookie']),
  },
}
</script>

サインイン時のAPIに対するPOST

APIに対してPOSTするとTokenが返却されます。

export const actions = {
  signIn({ commit }, body) {
    return new Promise((resolve, reject) => {
      this.$axios
        .post('/auth/sign_in', body)
        .then((res) => commit('SET_USER_TOKEN', res))
        .then((res) => resolve(res))
        .catch((err) => {
          alert('ログインに失敗しました')
          reject(err)
        })
    })
  },
}

返却されたTokenをStoreに保存

export const state = () => ({
  userToken: {},
})

export const mutations = {
  SET_USER_TOKEN(state, val) {
    state.userToken = {
      accessToken: val.headers['access-token'],
      client: val.headers.client,
      uid: val.headers.uid,
    }
  },
}

ExpressにStoreに保存されたTokenをPOST

export const actions = {
  setTokenInCookie() {
    this.$axios.post(process.env.HOST + '/api/cookie', {
      accessToken: this.state.user.userToken.accessToken,
      client: this.state.user.userToken.client,
      uid: this.state.user.userToken.uid,
    })
  },
}

※nuxt.config.jsの設定は省きました

ExpressでCookieを設定

const express = require('express')
const cookieParser = require('cookie-parser')

const app = express()
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use(cookieParser())

app.post('/cookie', (req, res) => {
  res.cookie('access-token', req.body.accessToken, {
    maxAge: 60 * 60 * 24 * 14,
    secure: process.env.HOST !== 'http://localhost:3333', // secure属性は開発環境はfalseにしておきましょう
    httpOnly: true,
    sameSite: 'strict',
  })
  res.cookie('uid', req.body.uid, {
    maxAge: 60 * 60 * 24 * 14,
    secure: process.env.HOST !== 'http://localhost:3333',
    httpOnly: true,
    sameSite: 'strict',
  })
  res.cookie('client', req.body.client, {
    maxAge: 60 * 60 * 24 * 14,
    secure: process.env.HOST !== 'http://localhost:3333',
    httpOnly: true,
    sameSite: 'strict',
  })
  res.json({ message: 'success' }) // 何か適当にレスポンスしてあげてください
})

module.exports = {
  path: '/api',
  handler: app,
}

これで一通りCookieは保存出来たので取り出すだけですね。

nuxtServerInitでCookieの中身を取り出す

export const actions = {
  async nuxtServerInit({ dispatch }, req) {
    const accessToken = req.app.$cookies.get('access-token')
    const client = req.app.$cookies.get('client')
    const uid = req.app.$cookies.get('uid')
    if (accessToken && client && uid) {
      await dispatch('user/setTokenInStore', {
        accessToken,
        client,
        uid,
      })
    }
  },
}

取り出した中身をStoreに保存

export const state = () => ({
  userToken: {},
})

export const mutations = {
  SET_USER_TOKEN_SSR(state, val) {
    state.userToken = {
      accessToken: val.accessToken,
      client: val.client,
      uid: val.uid,
    }
  },
}

export const actions = {
  setTokenInStore({ commit }, token) {
    commit('SET_USER_TOKEN_SSR', token)
  },
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【初心者】ふーんPWAってこうゆうことかとなる

今回PWAを勉強した理由は「Google サンタを追いかけよう」の存在を知って周りが盛り上がり、PWAってなんや?ってところからでした。いつまで知らなかったんや
僕自身もフロント全般的に知識が浅いので初心者の方にもなんとなく理解できるんじゃないかなと思っています。

PWAってなんすか?

よく説明されるのはPWAとは「Progressive Web Apps」の略称でモバイル向けのwebサイトをアプリのように使える仕組みと言われています。
PWAはアプリとWebサイトの側面を持ち合わせています。 普段アプリを使っていればなんとなくイメージはつくのではないでしょうか?

一点注意したいのは、1つの技術をPWAと言うのではなく、様々なGoogleが定めている要素を備えたウェブアプリのことを言います
定める要素は主に4つに分類されています。:relaxed:

分類項目 意味
Reliable ネットワークの状態に関係なく読み込むことができる。
Integrated 体験をよりデバイス・OSに合わせた形にする。
Fast 操作に素早く反応すること。
Engaging のめり込むようなUXがあること。

んー・・・概念的すぎてよくわからないっすよね。
後に機能や技術の話をしますが、push通知やオフライン対応で解釈してしまうとそれはPWAの一部にすぎません。
学習を通して自分の解釈としては、PWAとはよりよいウェブ体験をアプリのように作ることができることではないかなと思っています。
なぜかと言うと下の表のようにwebとアプリを比較してwebでできなかったことがPWAではできるからです。

PWAとwebサイト・ネイティブアプリの比較表
PWA比較表.png

引用元:https://slidesplayer.net/slide/14321401/

PWAでできること

比較表でネタバレしている部分もあるのですが、代表例を紹介させてください。

ユーザーの端末にインストールできる

webサイトからアプリへ誘致するためにはインストールという手間が入るように、ユーザーと企業にとっての利用障壁となる存在は今も課題です。
しかしブラウザ上でアプリと同じサービスを旋回できるため、利用障壁がなくなる=離脱率やCVR改善が考えられます。
メリットにもなるのですが、例えばPWAではなくアプリを開発するとしてこの資料をみてください。

アプリのインストール数.png

スマホ利用者に、アプリ利用状況を調査!アプリ課金状況や、インストール個数が明らかに

アプリであればたくさんのアプリ市場の中で勝ち取りにいく必要があります。
PWAではwebサイトで流入してくるユーザーをアプリへ誘致せずにインストールさせることができるので違う角度からアプローチできています。

ネイティブアプリのような使用感を手に入れれる。

プッシュ通知

webアプリを入れているユーザーにしかプッシュ通知できませんでしたが、PWAを通してプッシュ通知を行うことができるようになりました。
プッシュ通知はメールと違い、URLをクリックするまでの段階が浅くユーザーも早く情報にたどり着けます。
またメールよりもクリック率が3倍ほど高かった実績もあるようです。
そのため接触率が高くなったりリマインド機能としての効果もあり継続して利用してもらう施策としても有効な手段ではないかと思います。

オフラインでも操作可能

キャッシュを利用してページを表示させるため、オフラインでもサイトを閲覧することができます。
オフラインでできることってそんなに必要とされているのか?と思われた方もいると思いますが、生活の中で接続が悪い場合や、接続を切らないといけない状況などで活躍します。
一番イメージできるのはgoogleのスプレッドシートやドキュメントもオフラインで操作できオンラインに戻った時データを更新してくれます。

デバイスの機能を利用できる

・カメラ
・位置情報
・音声入力
・ジャイロスコープなどのセンサー
などデバイスの機能を利用することができます。

今ではIPアドレスベースでの地域ターゲティングだったのがPWA対応によって、GPSを利用した精度の高い地域ターゲティングを行うことが可能になります。

表示速度の高速化

PWAはキャッシュを利用するためページの読み込み速度が早くなります。

PWAのメリット

PWAでできることを紹介しましたが、PWAを利用することで以下のようなメリットがあります。

アプリで必要だったリリース審査が不要

ネイティブアプリの場合、リリースするまでに審査があり時間がかかります。
PWAの場合webサイトなので導入障壁もなく最速で実装し、PDCAを回すことができます。

ユーザーの直帰率を下げられる

ページの表示速度が3秒を越えると直帰率が高まることや、ページの読み込みが1秒増すとユーザーが10%減少するといったデータも存在します。
PWAのサイトは表示速度を高速化できるため一般的なwebサイトよりも直帰率を下げることができる。

ユーザーと繋がる

本来ではネイティブアプリでしかできなかったプッシュ通知がPWAでは行えます。
例えば一定の間再訪問がないユーザーに対してやキャンペーンを告知することができます。
サイト訪問や購買に繋げることもできる大きなメリットです。

ネイティブアプリのようなUIにすることができる

・ホームに追加ボタンを表示することができる。(UIなのか微妙)
・スプラッシュ画面を設定できる
・アプリのように全画面表示ができる

PWAのデメリット

アプリとPWAを両方やるとコストがかかる

すでにアプリを運用している場合PWAもアプリのようなユーザー体験を提供するため重複しコストがかかってしまうなと思いました。

iOSでは一部の機能対応していない

データ量が制限されている

オフラインデータは50MBまでに制限がされています。
ユーザーがオフラインで操作する場合は注意が必要

プッシュ通知が使えない

プッシュ通知のメリットが重要だと思っている方には残念ですがiOSでは対応していません。

PWAは普段のweb作業にプラスでついてくるもの

PWAは一般的なコーディング・開発に+としてPWA対応の作業が乗ってきます。
その分一回におけるリソースが多くなる。

PWAを導入するためには

ここまでPWAの概要について説明してきましたが、実際にPWAの導入をしていきます。
PWAを導入するにあたって3つの条件があるので紹介していきます。

HTTPSが必要

PWA対応させるためにはService Workerが必須になります。
ServiceWorkerを使うと改ざんやフィルタリングができてしまうため悪用を避けるためHTTPSを介して提供されるページでしか登録することができません。

ウェブアプリマニフェストの作成

PWAの基本設定を記述するウェブアプリマニフェスト(JSONファイル)が必要です。
ウェブアプリケーションについて、ウェブアプリをダウンロードしたり、ネイティブアプリと同じように見せたり必要な情報を提供するためです。
詳しくはこちらをご覧ください。
WebManifest

マニフェストは画像も含めて作成してくれるこちらのサイトがオススメです。
ただし全てをしてくれるわけではないので注意してください。
Web App Manifest Generator

実際に自分が書いたコードはこちらです。

manifest.json
{
  "name": "swpractice", 
  "short_name": "swpractice",
  "theme_color": "#4cb7c3",
  "background_color": "#ececec",
  "display": "standalone",
  "Scope": "/",
  "start_url": "/",
  "icons": [
    {
      "src": "images/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
}

詳しくは添付したリンクをみて欲しいですが、自分が設定した内容を説明させて下さい。

項目 意味
name ウェブアプリケーションの名前をユーザーに表示させる
short_name nameを表示するのに十分にスペースがない場合に使用される。(例えばホーム画面)
theme_color 既存のテーマカラーの定義。これはOSがどうのように表示するかに影響がある。ツールバーとか
background_color スタイルシートが読み込まれる前にアプリケーションの背景色を定義する。スプラッシュ画面の背景
display ウェブサイトの表示モードを指定する。UIをどう見せるのか
Scope どこまでこのマニフェストを見せるのか制限させる。そのスコープ外にいくと通常のwebサイトに戻る
start_url アプリケーションの開始URLを定義する。ホーム画面のアイコンをタップした時に表示される最初のページ
icons 様々な場面でアイコンとして機能する画像定義

といった形です!

Service Worker

詳しくお話しませんがこのようなソースコードです。

serviceWorker.js
'use strict';

const CACHE_NAME = 'pwa-v3'; //キャッシュさせる時のキャッシュ名
const urlsToCache = [
  './',
  './index.html',
  './common.css',
  './main.js',
  './manifest.json',
  '/images/index/hapikuru.jpg',
  '/images/icons/icon-144x144.png'
];

self.addEventListener('install', (event) => { //install時
  event.waitUntil(
    caches.open(CACHE_NAME) //キャッシュを開く
      .then((cache) => {
        return cache.addAll(urlsToCache); //指定されているリソースをキャッシュに保存させる。
      })
  );
});

// キャッシュcaches.openで開いて、cache.addAllで保存させる。
// event.waitUntilはPromiseをとって、インストールが成功仕方を確認するために使う。
//  ┗https://developer.mozilla.org/ja/docs/Web/API/ExtendableEvent/waitUntil
// ※ファイル数が多くなればなるほどキャッシュが失敗してService Workerがインストールされない確率も高くなる。



self.addEventListener('active', (event) => {
  var cacheWhitelist = [CACHE_NAME];

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {  //ホワイトリストにないキャッシュは削除させる。
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName); //キャッシュを消す。
          }
        })
      );
    })
  );
});

self.addEventListener('fetch', (event) => { //ページの更新などで、Service Workerはfetchイベントを受け取る。
  event.respondWith(
    caches.match(event.request) 
     .then((response) => { 
       if (response) {
         return response;
       }

       let fetchRequest = event.request.clone();
       return fetch(fetchRequest)
        .then((response) => {
          if(!response || response.status !== 200 || response.type !== 'basic') {
            //レスポンスが正しいか、レスポンスのステータスが200か、レスポンスの型がbasicか。(リクエストの送信元と送信先のドメインが同じである)
            return response;
          }

          let responseToCache = response.clone();
          cache.open(CACHE_NAME)
            .then((cache) => {
              cache.put(event.request, responseToCache); //リクエストとレスポンスを受け取り指定されたキャッシュへ追加する。
            });
            return response;
        });
     })
  );
});

ServiceWorkerってなに?

ブラウザがwebページとは別にバックグラウンドで実行するスクリプトです。
プッシュ通知やバックグラウンド同期などオフライン対応などがこの人がやってくれています。

僕が学習をしてとても参考になりお世話になった記事を2つ共有させていただきます。

Service Workerってなんなのよ (Service Workerのえほん)
Service Workerの概要がイラスト付きで説明されているので直感的にわかりやすかったです。

Service Workerの基本とそれを使ってできること
Service Workerの扱い方を詳しく説明してくれています。

まとめ

ここまでPWAとは何か、メリットデメリット、導入方法について、Service Workerについて紹介させていただきました。
ServiceWorkerについてはとても簡潔になってしまい申し訳ございません。
余裕があるときにService Workerについて詳しく記事にしたいと思います。

PWAはネイティブアプリの代用品となるものではなく、ユーザーがより使いやすくなるようにWebサイトの延長線上にPWAが存在していると思います。
それが伝われば嬉しいです。


ここからは番外編です。

自分が作ったものを紹介

こちらです!
https://pwa-delta.vercel.app/
オフラインで試してみて下さい。通知を許可してもらってもいいですが今後もプッシュ通知など勉強で送るので気をつけて下さい・・・

ソースはこちらです。(コミットはまじクソなのでみないでくださいtestしか書いてない)
https://github.com/wrsdu1715/pwa

push通知を簡単にできるよ

push通知は技術的にちょっと難しいので僕はツールに逃げさせてもらいました(後にチャレンジします)
ちょっとそのツールを紹介させて下さい。
onesignal
こちらは無料で使用できるツールになります。headにCDNを読み込ませるだけでpush通知を送ることもできますし何人が登録しているのかpush通知をクリックした人数なども計測できるのでおすすめです!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【初心者】これで大体PWAがわかる!

今回PWAを勉強した理由は「Google サンタを追いかけよう」の存在を知って周りが盛り上がり、PWAってなんや?ってところからでした。いつまで知らなかったんや
僕自身もフロント全般的に知識が浅いので初心者の方にもなんとなく理解できるんじゃないかなと思っています。

PWAってなんすか?

よく説明されるのはPWAとは「Progressive Web Apps」の略称でモバイル向けのwebサイトをアプリのように使える仕組みと言われています。
PWAはアプリとWebサイトの側面を持ち合わせています。 普段アプリを使っていればなんとなくイメージはつくのではないでしょうか?

一点注意したいのは、1つの技術をPWAと言うのではなく、様々なGoogleが定めている要素を備えたウェブアプリのことを言います
定める要素は主に4つに分類されています。:relaxed:

分類項目 意味
Reliable ネットワークの状態に関係なく読み込むことができる。
Integrated 体験をよりデバイス・OSに合わせた形にする。
Fast 操作に素早く反応すること。
Engaging のめり込むようなUXがあること。

んー・・・概念的すぎてよくわからないっすよね。
後に機能や技術の話をしますが、push通知やオフライン対応で解釈してしまうとそれはPWAの一部にすぎません。
学習を通して自分の解釈としては、PWAとはよりよいウェブ体験をアプリのように作ることができることではないかなと思っています。
なぜかと言うと下の表のようにwebとアプリを比較してwebでできなかったことがPWAではできるからです。

PWAとwebサイト・ネイティブアプリの比較表
PWA比較表.png

引用元:https://slidesplayer.net/slide/14321401/

PWAでできること

比較表でネタバレしている部分もあるのですが、代表例を紹介させてください。

ユーザーの端末にインストールできる

webサイトからアプリへ誘致するためにはインストールという手間が入るように、ユーザーと企業にとっての利用障壁となる存在は今も課題です。
しかしブラウザ上でアプリと同じサービスを旋回できるため、利用障壁がなくなる=離脱率やCVR改善が考えられます。
メリットにもなるのですが、例えばPWAではなくアプリを開発するとしてこの資料をみてください。

アプリのインストール数.png

スマホ利用者に、アプリ利用状況を調査!アプリ課金状況や、インストール個数が明らかに

アプリであればたくさんのアプリ市場の中で勝ち取りにいく必要があります。
PWAではwebサイトで流入してくるユーザーをアプリへ誘致せずにインストールさせることができるので違う角度からアプローチできています。

ネイティブアプリのような使用感を手に入れれる。

プッシュ通知

webアプリを入れているユーザーにしかプッシュ通知できませんでしたが、PWAを通してプッシュ通知を行うことができるようになりました。
プッシュ通知はメールと違い、URLをクリックするまでの段階が浅くユーザーも早く情報にたどり着けます。
またメールよりもクリック率が3倍ほど高かった実績もあるようです。
そのため接触率が高くなったりリマインド機能としての効果もあり継続して利用してもらう施策としても有効な手段ではないかと思います。

オフラインでも操作可能

キャッシュを利用してページを表示させるため、オフラインでもサイトを閲覧することができます。
オフラインでできることってそんなに必要とされているのか?と思われた方もいると思いますが、生活の中で接続が悪い場合や、接続を切らないといけない状況などで活躍します。
一番イメージできるのはgoogleのスプレッドシートやドキュメントもオフラインで操作できオンラインに戻った時データを更新してくれます。

デバイスの機能を利用できる

・カメラ
・位置情報
・音声入力
・ジャイロスコープなどのセンサー
などデバイスの機能を利用することができます。

今ではIPアドレスベースでの地域ターゲティングだったのがPWA対応によって、GPSを利用した精度の高い地域ターゲティングを行うことが可能になります。

表示速度の高速化

PWAはキャッシュを利用するためページの読み込み速度が早くなります。

PWAのメリット

PWAでできることを紹介しましたが、PWAを利用することで以下のようなメリットがあります。

アプリで必要だったリリース審査が不要

ネイティブアプリの場合、リリースするまでに審査があり時間がかかります。
PWAの場合webサイトなので導入障壁もなく最速で実装し、PDCAを回すことができます。

ユーザーの直帰率を下げられる

ページの表示速度が3秒を越えると直帰率が高まることや、ページの読み込みが1秒増すとユーザーが10%減少するといったデータも存在します。
PWAのサイトは表示速度を高速化できるため一般的なwebサイトよりも直帰率を下げることができる。

ユーザーと繋がる

本来ではネイティブアプリでしかできなかったプッシュ通知がPWAでは行えます。
例えば一定の間再訪問がないユーザーに対してやキャンペーンを告知することができます。
サイト訪問や購買に繋げることもできる大きなメリットです。

ネイティブアプリのようなUIにすることができる

・ホームに追加ボタンを表示することができる。(UIなのか微妙)
・スプラッシュ画面を設定できる
・アプリのように全画面表示ができる

PWAのデメリット

アプリとPWAを両方やるとコストがかかる

すでにアプリを運用している場合PWAもアプリのようなユーザー体験を提供するため重複しコストがかかってしまうなと思いました。

iOSでは一部の機能対応していない

データ量が制限されている

オフラインデータは50MBまでに制限がされています。
ユーザーがオフラインで操作する場合は注意が必要

プッシュ通知が使えない

プッシュ通知のメリットが重要だと思っている方には残念ですがiOSでは対応していません。

PWAは普段のweb作業にプラスでついてくるもの

PWAは一般的なコーディング・開発に+としてPWA対応の作業が乗ってきます。
その分一回におけるリソースが多くなる。

PWAを導入するためには

ここまでPWAの概要について説明してきましたが、実際にPWAの導入をしていきます。
PWAを導入するにあたって3つの条件があるので紹介していきます。

HTTPSが必要

PWA対応させるためにはService Workerが必須になります。
ServiceWorkerを使うと改ざんやフィルタリングができてしまうため悪用を避けるためHTTPSを介して提供されるページでしか登録することができません。

ウェブアプリマニフェストの作成

PWAの基本設定を記述するウェブアプリマニフェスト(JSONファイル)が必要です。
ウェブアプリケーションについて、ウェブアプリをダウンロードしたり、ネイティブアプリと同じように見せたり必要な情報を提供するためです。
詳しくはこちらをご覧ください。
WebManifest

マニフェストは画像も含めて作成してくれるこちらのサイトがオススメです。
ただし全てをしてくれるわけではないので注意してください。
Web App Manifest Generator

実際に自分が書いたコードはこちらです。

manifest.json
{
  "name": "swpractice", 
  "short_name": "swpractice",
  "theme_color": "#4cb7c3",
  "background_color": "#ececec",
  "display": "standalone",
  "Scope": "/",
  "start_url": "/",
  "icons": [
    {
      "src": "images/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
}

詳しくは添付したリンクをみて欲しいですが、自分が設定した内容を説明させて下さい。

項目 意味
name ウェブアプリケーションの名前をユーザーに表示させる
short_name nameを表示するのに十分にスペースがない場合に使用される。(例えばホーム画面)
theme_color 既存のテーマカラーの定義。これはOSがどうのように表示するかに影響がある。ツールバーとか
background_color スタイルシートが読み込まれる前にアプリケーションの背景色を定義する。スプラッシュ画面の背景
display ウェブサイトの表示モードを指定する。UIをどう見せるのか
Scope どこまでこのマニフェストを見せるのか制限させる。そのスコープ外にいくと通常のwebサイトに戻る
start_url アプリケーションの開始URLを定義する。ホーム画面のアイコンをタップした時に表示される最初のページ
icons 様々な場面でアイコンとして機能する画像定義

といった形です!

Service Worker

詳しくお話しませんがこのようなソースコードです。

serviceWorker.js
'use strict';

const CACHE_NAME = 'pwa-v3'; //キャッシュさせる時のキャッシュ名
const urlsToCache = [
  './',
  './index.html',
  './common.css',
  './main.js',
  './manifest.json',
  '/images/index/hapikuru.jpg',
  '/images/icons/icon-144x144.png'
];

self.addEventListener('install', (event) => { //install時
  event.waitUntil(
    caches.open(CACHE_NAME) //キャッシュを開く
      .then((cache) => {
        return cache.addAll(urlsToCache); //指定されているリソースをキャッシュに保存させる。
      })
  );
});

// キャッシュcaches.openで開いて、cache.addAllで保存させる。
// event.waitUntilはPromiseをとって、インストールが成功仕方を確認するために使う。
//  ┗https://developer.mozilla.org/ja/docs/Web/API/ExtendableEvent/waitUntil
// ※ファイル数が多くなればなるほどキャッシュが失敗してService Workerがインストールされない確率も高くなる。



self.addEventListener('active', (event) => {
  var cacheWhitelist = [CACHE_NAME];

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {  //ホワイトリストにないキャッシュは削除させる。
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName); //キャッシュを消す。
          }
        })
      );
    })
  );
});

self.addEventListener('fetch', (event) => { //ページの更新などで、Service Workerはfetchイベントを受け取る。
  event.respondWith(
    caches.match(event.request) 
     .then((response) => { 
       if (response) {
         return response;
       }

       let fetchRequest = event.request.clone();
       return fetch(fetchRequest)
        .then((response) => {
          if(!response || response.status !== 200 || response.type !== 'basic') {
            //レスポンスが正しいか、レスポンスのステータスが200か、レスポンスの型がbasicか。(リクエストの送信元と送信先のドメインが同じである)
            return response;
          }

          let responseToCache = response.clone();
          cache.open(CACHE_NAME)
            .then((cache) => {
              cache.put(event.request, responseToCache); //リクエストとレスポンスを受け取り指定されたキャッシュへ追加する。
            });
            return response;
        });
     })
  );
});

ServiceWorkerってなに?

ブラウザがwebページとは別にバックグラウンドで実行するスクリプトです。
プッシュ通知やバックグラウンド同期などオフライン対応などがこの人がやってくれています。

僕が学習をしてとても参考になりお世話になった記事を2つ共有させていただきます。

Service Workerってなんなのよ (Service Workerのえほん)
Service Workerの概要がイラスト付きで説明されているので直感的にわかりやすかったです。

Service Workerの基本とそれを使ってできること
Service Workerの扱い方を詳しく説明してくれています。

まとめ

ここまでPWAとは何か、メリットデメリット、導入方法について、Service Workerについて紹介させていただきました。
ServiceWorkerについてはとても簡潔になってしまい申し訳ございません。
余裕があるときにService Workerについて詳しく記事にしたいと思います。

PWAはネイティブアプリの代用品となるものではなく、ユーザーがより使いやすくなるようにWebサイトの延長線上にPWAが存在していると思います。
それが伝われば嬉しいです。


ここからは番外編です。

自分が作ったものを紹介

こちらです!
https://pwa-delta.vercel.app/
オフラインで試してみて下さい。通知を許可してもらってもいいですが今後もプッシュ通知など勉強で送るので気をつけて下さい・・・

ソースはこちらです。(コミットはまじクソなのでみないでくださいtestしか書いてない)
https://github.com/wrsdu1715/pwa

push通知を簡単にできるよ

push通知は技術的にちょっと難しいので僕はツールに逃げさせてもらいました(後にチャレンジします)
ちょっとそのツールを紹介させて下さい。
onesignal
こちらは無料で使用できるツールになります。headにCDNを読み込ませるだけでpush通知を送ることもできますし何人が登録しているのかpush通知をクリックした人数なども計測できるのでおすすめです!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

PWAとは何か?大体わかるようになる

今回PWAを勉強した理由は「Google サンタを追いかけよう」の存在を知って周りが盛り上がり、PWAってなんや?ってところからでした。いつまで知らなかったんや
僕自身もフロント全般的に知識が浅いので初心者の方にもなんとなく理解できるんじゃないかなと思っています。

PWAってなんすか?

よく説明されるのはPWAとは「Progressive Web Apps」の略称でモバイル向けのwebサイトをアプリのように使える仕組みと言われています。
PWAはアプリとWebサイトの側面を持ち合わせています。 普段アプリを使っていればなんとなくイメージはつくのではないでしょうか?

一点注意したいのは、1つの技術をPWAと言うのではなく、様々なGoogleが定めている要素を備えたウェブアプリのことを言います
定める要素は主に4つに分類されています。:relaxed:

分類項目 意味
Reliable ネットワークの状態に関係なく読み込むことができる。
Integrated 体験をよりデバイス・OSに合わせた形にする。
Fast 操作に素早く反応すること。
Engaging のめり込むようなUXがあること。

んー・・・概念的すぎてよくわからないっすよね。
後に機能や技術の話をしますが、push通知やオフライン対応で解釈してしまうとそれはPWAの一部にすぎません。
学習を通して自分の解釈としては、PWAとはよりよいウェブ体験をアプリのように作ることができることではないかなと思っています。
なぜかと言うと下の表のようにwebとアプリを比較してwebでできなかったことがPWAではできるからです。

PWAとwebサイト・ネイティブアプリの比較表
PWA比較表.png

引用元:https://slidesplayer.net/slide/14321401/

PWAでできること

比較表でネタバレしている部分もあるのですが、代表例を紹介させてください。

ユーザーの端末にインストールできる

webサイトからアプリへ誘致するためにはインストールという手間が入るように、ユーザーと企業にとっての利用障壁となる存在は今も課題です。
しかしブラウザ上でアプリと同じサービスを旋回できるため、利用障壁がなくなる=離脱率やCVR改善が考えられます。
メリットにもなるのですが、例えばPWAではなくアプリを開発するとしてこの資料をみてください。

アプリのインストール数.png

スマホ利用者に、アプリ利用状況を調査!アプリ課金状況や、インストール個数が明らかに

アプリであればたくさんのアプリ市場の中で勝ち取りにいく必要があります。
PWAではwebサイトで流入してくるユーザーをアプリへ誘致せずにインストールさせることができるので違う角度からアプローチできています。

ネイティブアプリのような使用感を手に入れれる。

プッシュ通知

webアプリを入れているユーザーにしかプッシュ通知できませんでしたが、PWAを通してプッシュ通知を行うことができるようになりました。
プッシュ通知はメールと違い、URLをクリックするまでの段階が浅くユーザーも早く情報にたどり着けます。
またメールよりもクリック率が3倍ほど高かった実績もあるようです。
そのため接触率が高くなったりリマインド機能としての効果もあり継続して利用してもらう施策としても有効な手段ではないかと思います。

オフラインでも操作可能

キャッシュを利用してページを表示させるため、オフラインでもサイトを閲覧することができます。
オフラインでできることってそんなに必要とされているのか?と思われた方もいると思いますが、生活の中で接続が悪い場合や、接続を切らないといけない状況などで活躍します。
一番イメージできるのはgoogleのスプレッドシートやドキュメントもオフラインで操作できオンラインに戻った時データを更新してくれます。

デバイスの機能を利用できる

・カメラ
・位置情報
・音声入力
・ジャイロスコープなどのセンサー
などデバイスの機能を利用することができます。

今ではIPアドレスベースでの地域ターゲティングだったのがPWA対応によって、GPSを利用した精度の高い地域ターゲティングを行うことが可能になります。

表示速度の高速化

PWAはキャッシュを利用するためページの読み込み速度が早くなります。

PWAのメリット

PWAでできることを紹介しましたが、PWAを利用することで以下のようなメリットがあります。

アプリで必要だったリリース審査が不要

ネイティブアプリの場合、リリースするまでに審査があり時間がかかります。
PWAの場合webサイトなので導入障壁もなく最速で実装し、PDCAを回すことができます。

ユーザーの直帰率を下げられる

ページの表示速度が3秒を越えると直帰率が高まることや、ページの読み込みが1秒増すとユーザーが10%減少するといったデータも存在します。
PWAのサイトは表示速度を高速化できるため一般的なwebサイトよりも直帰率を下げることができる。

ユーザーと繋がる

本来ではネイティブアプリでしかできなかったプッシュ通知がPWAでは行えます。
例えば一定の間再訪問がないユーザーに対してやキャンペーンを告知することができます。
サイト訪問や購買に繋げることもできる大きなメリットです。

ネイティブアプリのようなUIにすることができる

・ホームに追加ボタンを表示することができる。(UIなのか微妙)
・スプラッシュ画面を設定できる
・アプリのように全画面表示ができる

PWAのデメリット

アプリとPWAを両方やるとコストがかかる

すでにアプリを運用している場合PWAもアプリのようなユーザー体験を提供するため重複しコストがかかってしまうなと思いました。

iOSでは一部の機能対応していない

データ量が制限されている

オフラインデータは50MBまでに制限がされています。
ユーザーがオフラインで操作する場合は注意が必要

プッシュ通知が使えない

プッシュ通知のメリットが重要だと思っている方には残念ですがiOSでは対応していません。

PWAは普段のweb作業にプラスでついてくるもの

PWAは一般的なコーディング・開発に+としてPWA対応の作業が乗ってきます。
その分一回におけるリソースが多くなる。

PWAを導入するためには

ここまでPWAの概要について説明してきましたが、実際にPWAの導入をしていきます。
PWAを導入するにあたって3つの条件があるので紹介していきます。

HTTPSが必要

PWA対応させるためにはService Workerが必須になります。
ServiceWorkerを使うと改ざんやフィルタリングができてしまうため悪用を避けるためHTTPSを介して提供されるページでしか登録することができません。

ウェブアプリマニフェストの作成

PWAの基本設定を記述するウェブアプリマニフェスト(JSONファイル)が必要です。
ウェブアプリケーションについて、ウェブアプリをダウンロードしたり、ネイティブアプリと同じように見せたり必要な情報を提供するためです。
詳しくはこちらをご覧ください。
WebManifest

マニフェストは画像も含めて作成してくれるこちらのサイトがオススメです。
ただし全てをしてくれるわけではないので注意してください。
Web App Manifest Generator

実際に自分が書いたコードはこちらです。

manifest.json
{
  "name": "swpractice", 
  "short_name": "swpractice",
  "theme_color": "#4cb7c3",
  "background_color": "#ececec",
  "display": "standalone",
  "Scope": "/",
  "start_url": "/",
  "icons": [
    {
      "src": "images/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
}

詳しくは添付したリンクをみて欲しいですが、自分が設定した内容を説明させて下さい。

項目 意味
name ウェブアプリケーションの名前をユーザーに表示させる
short_name nameを表示するのに十分にスペースがない場合に使用される。(例えばホーム画面)
theme_color 既存のテーマカラーの定義。これはOSがどうのように表示するかに影響がある。ツールバーとか
background_color スタイルシートが読み込まれる前にアプリケーションの背景色を定義する。スプラッシュ画面の背景
display ウェブサイトの表示モードを指定する。UIをどう見せるのか
Scope どこまでこのマニフェストを見せるのか制限させる。そのスコープ外にいくと通常のwebサイトに戻る
start_url アプリケーションの開始URLを定義する。ホーム画面のアイコンをタップした時に表示される最初のページ
icons 様々な場面でアイコンとして機能する画像定義

といった形です!

Service Worker

詳しくお話しませんがこのようなソースコードです。

serviceWorker.js
'use strict';

const CACHE_NAME = 'pwa-v3'; //キャッシュさせる時のキャッシュ名
const urlsToCache = [
  './',
  './index.html',
  './common.css',
  './main.js',
  './manifest.json',
  '/images/index/hapikuru.jpg',
  '/images/icons/icon-144x144.png'
];

self.addEventListener('install', (event) => { //install時
  event.waitUntil(
    caches.open(CACHE_NAME) //キャッシュを開く
      .then((cache) => {
        return cache.addAll(urlsToCache); //指定されているリソースをキャッシュに保存させる。
      })
  );
});

// キャッシュcaches.openで開いて、cache.addAllで保存させる。
// event.waitUntilはPromiseをとって、インストールが成功仕方を確認するために使う。
//  ┗https://developer.mozilla.org/ja/docs/Web/API/ExtendableEvent/waitUntil
// ※ファイル数が多くなればなるほどキャッシュが失敗してService Workerがインストールされない確率も高くなる。



self.addEventListener('active', (event) => {
  var cacheWhitelist = [CACHE_NAME];

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {  //ホワイトリストにないキャッシュは削除させる。
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName); //キャッシュを消す。
          }
        })
      );
    })
  );
});

self.addEventListener('fetch', (event) => { //ページの更新などで、Service Workerはfetchイベントを受け取る。
  event.respondWith(
    caches.match(event.request) 
     .then((response) => { 
       if (response) {
         return response;
       }

       let fetchRequest = event.request.clone();
       return fetch(fetchRequest)
        .then((response) => {
          if(!response || response.status !== 200 || response.type !== 'basic') {
            //レスポンスが正しいか、レスポンスのステータスが200か、レスポンスの型がbasicか。(リクエストの送信元と送信先のドメインが同じである)
            return response;
          }

          let responseToCache = response.clone();
          cache.open(CACHE_NAME)
            .then((cache) => {
              cache.put(event.request, responseToCache); //リクエストとレスポンスを受け取り指定されたキャッシュへ追加する。
            });
            return response;
        });
     })
  );
});

ServiceWorkerってなに?

ブラウザがwebページとは別にバックグラウンドで実行するスクリプトです。
プッシュ通知やバックグラウンド同期などオフライン対応などがこの人がやってくれています。

僕が学習をしてとても参考になりお世話になった記事を2つ共有させていただきます。

Service Workerってなんなのよ (Service Workerのえほん)
Service Workerの概要がイラスト付きで説明されているので直感的にわかりやすかったです。

Service Workerの基本とそれを使ってできること
Service Workerの扱い方を詳しく説明してくれています。

まとめ

ここまでPWAとは何か、メリットデメリット、導入方法について、Service Workerについて紹介させていただきました。
ServiceWorkerについてはとても簡潔になってしまい申し訳ございません。
余裕があるときにService Workerについて詳しく記事にしたいと思います。

PWAはネイティブアプリの代用品となるものではなく、ユーザーがより使いやすくなるようにWebサイトの延長線上にPWAが存在していると思います。
それが伝われば嬉しいです。


ここからは番外編です。

自分が作ったものを紹介

こちらです!
https://pwa-delta.vercel.app/
オフラインで試してみて下さい。通知を許可してもらってもいいですが今後もプッシュ通知など勉強で送るので気をつけて下さい・・・

ソースはこちらです。(コミットはまじクソなのでみないでくださいtestしか書いてない)
https://github.com/wrsdu1715/pwa

push通知を簡単にできるよ

push通知は技術的にちょっと難しいので僕はツールに逃げさせてもらいました(後にチャレンジします)
ちょっとそのツールを紹介させて下さい。
onesignal
こちらは無料で使用できるツールになります。headにCDNを読み込ませるだけでpush通知を送ることもできますし何人が登録しているのかpush通知をクリックした人数なども計測できるのでおすすめです!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む