- 投稿日:2019-12-19T23:51:22+09:00
Vueで日本全国ダーツの旅的なものを作ってみた
テックタッチアドベントカレンダー19日目を担当する@kosyです。
18日目は @92thunder による Vuex × TypeScriptにvuex-smart-moduleを選んだ理由 でした。
vuex-smart-moduleがどう良いのかわかる内容で理解が深まりました。型に苦しめられた過去があるからこそ今の成長があるわけですね。
vuex-smart-moduleに即決定したスピード感、さすがテックタッチ。はじめに
日本全国ダーツの旅みたいな感じでランダムに市区町村出してくれるサイトを作りました。
場所だけじゃなく、おすすめ観光スポットも表示されます。
エンジニアに転職した今年4月当初に作ろうとしましたが、何をどうすれば良いのか全くわからず結局何も作れないまま終わりました。9ヶ月経った今なら作れる!ということで作ってみました。
ぜひ遊んでみてください。成果物
https://random-travel.netlify.com/
作成手順
1. 市区町村コードを取得できるAPIの準備
2. Puppeteerで観光地の情報収集
3. Vueでページを作る
4. Netlifyでアップロード1. 市区町村コードを取得できるAPIの準備
内閣府 地方創生推進室が提供するAPIで市区町村コードが取得できるため利用登録 → RESAS
↓こんな感じで都道府県コードを入力すると、その都道府県の市区町村一覧が取得できます。(市区町村一覧API){ "message": null, "result": [{ "prefCode": 1, "cityCode": "01100", "cityName": "札幌市", "bigCityFlag": "2" }, { "prefCode": 1, "cityCode": "01101", "cityName": "札幌市中央区", "bigCityFlag": "1" }, { … }都道府県コードのAPIでは、パラメータが指定できないので全都道府県のコードと名前が一括で返ってきます。なので、都道府県一覧は初回だけ取得したら一覧を保持しておきました。
登録できたらAPIキーが発行されるので、これを使ってリクエストを送ります。2. Puppeteerで観光地の情報収集
Puppeteerも業務で使用しているため勉強も兼ねて使用。
観光地情報はじゃらんから、地図画像はgoogleから取得しています。じゃらんにした理由は、人気観光地TOP3をまとめてくれている&地域毎の観光情報のURLが市区町村コード変えればそのページに行けるものなので情報収集しやすかったからです。
地図はgoogleにあった良い感じの画像のURLを取得して表示させています。
Google maps API使えば周辺地図も見れてよかったのかもしれませんが、市区町村の緯度経度をどこかから取得する必要があり時間と気力の問題から断念。Puppeteerは以前に書いた記事に操作方法をまとめています。基本的にはこの記事で書いた操作しかしていません。
Puppeteerで自動ブラウザ操作するときによく使う操作一覧収集したデータは市区町村ごとに{市区町村コード}.jsonファイルで保存し、vueでランダムに算出した市区町村コードのjsonファイルを呼び出しています。
3. Vueでページを作る
開発時は発行したAPIキーは、Vueのプロジェクト直下にenv.developmentファイルを作成し、そこに
VUE_APP_API_KEY=APIキーのように記載しておきます。
APIキーは悪用されると困るので直接コードに書き込むのではなく、環境変数で管理します。APIを使ってランダムで市区町村を取得するコードは以下のとおりです。
cityCodeList = { data: { message: null, result: [], statusCode: 0 }, status: 0 } // APIのレスポンスを格納する配列 all: any[] = [] prefCode: number = 0 resultCity = {} // 最終的に選ばれた市区町村を格納する変数 error: string = '' apiKey:string = process.env.VUE_APP_API_KEY config = { headers: { 'Content-Type': 'application/json', 'x-api-key': this.apiKey, }, } mounted() { this.calculateDestination(); } async calculateDestination() { this.prefCode = Math.floor((Math.random() * 47) + 1); // 都道府県を乱数で選定 // APIを使う await axios .get(`https://opendata.resas-portal.go.jp/api/v1/cities?prefCode=${this.prefCode}`, this.config) .then((response) => { this.cityCodeList = response; }) .catch((error) => { console.log(error); }); // レスポンスエラーになっていないか確認 if (this.checkNotError()) { // 市区町村を乱数で選定 const randomCity = Math.floor(Math.random() * this.cityCodeList.data.result.length); this.resultCity = this.cityCodeList.data.result[randomCity]; } } checkNotError() { if (this.cityCodeList.data.statusCode) { if (this.cityCodeList.data.statusCode === 429) { this.error = 'しばらく時間をおいてから試してみてください'; } else { this.error = '行き先を決められませんでした'; } return false; } return true; }観光情報を表示させる処理は以下の通りです。puppeteerで取得したJSONファイルを読み込むだけ。
async fetchSpotData(cityCode: string) { this.draftSpotData = await fetch(`./data/${cityCode}.json`) .then(response => response.json()); }4. Netlifyでアップロード
GitHubにコードをあげたら、NetlifyとGitHubを連携させデプロイします。
APIキーはNetlifyのSettings > Build&deploy > Environment で設定すればOK。
以降の更新はgitHubにpushする度に行われます。おわりに
アドベントカレンダーという機会がなければ、作成をずっと後回しにしてそうだったので今回は良い機会でした。
半年前は作れなかったものが作れるようになったのは嬉しい。明日20日目の担当は@mosimosiの「型論ではなくKatalon」です!乞うご期待!
- 投稿日:2019-12-19T23:09:11+09:00
socket.ioとvue.jsを利用したチャット作りましょう
こんにちは!
node.jsのモジュールであるsocket.ioを利用してチャットを作ってみました。socket.ioとは?
socket.ioはサーバとブラウザを双方向通信を可能にするnode.jsのモジュールです。HTML5のwebsocketプロトコルを基に様々なブラウザで動作する双方向通信が可能なアプリケーションを作ることができます。
socket.ioを利用すればチャットルームのようにリアルタイムにデータが伝送されるようなチャットを簡単に作るのができます。websocketとは
ウェブサーバの一つのポートでこの中で接続したクライアントが自由にデータをやり取りする方式です。
元来はクライアントからの要請があればサーバはそれに応答していたが、websocketはクライアントからの要請があったときにつながっている全てのクライアントに変更事項を伝達する仕組みです。
つまり、クライアントからの要請なしでもサーバの変更事項をクライアントにリアルタイムに配信できるようになりました。実装
サーバはexpress、フロントアンドはvue.jsを使用しました
↓はサーバコードです。とても簡単なコードでエクスプレスでサーバーを構成し socket.ioを連結しました。app.jsconst express = require('express'); const app = express(); const server = app.listen(3001, function() { console.log('server running on port 3001'); }); const io = require('socket.io')(server); io.on('connection', function(socket) { socket.on('SEND_MESSAGE', function(data) { io.emit('MESSAGE', data) }); });socketサーバーはクライアントの受信を'SEND_MESSAGE' という名前で待機し、この要請があったときにemitメソッドを通じて接続されたクライアントにデータを送信します。
↑のサーバコードを見れば、「MESSAGE」でサーバのデータを受信待機し、「SEND_MESSAGE」にデータを転送する仕組みです<template> <div> <h1>チャット</h1> <ul> <li v-for="(msg, index) in messages" :key="index">{{msg.message}}</li> </ul> <div> <input type="text" @keyup.enter="sendMessage()" v-model="message"> </div> </div> </template> <script> import io from 'socket.io-client'; export default { name: 'room', data() { return { messages: [], // 1) サーバ連結 socket : io('localhost:3001') } }, methods: { sendMessage(message) { // 2) メッセージをサーバで送信 this.socket.emit('SEND_MESSAGE', {message}); } }, mounted() { // 3) サーバの変更内容を受信 this.socket.on('MESSAGE', (data) => { this.messages = [...this.messages, data]; }); } } </script>これまでチャットためのクライアントとサーバの基本仕組みを作成してみました。
上のコードを通じてどのようにデータが行き来するかを理解することができました。参考
- 投稿日:2019-12-19T21:16:27+09:00
GoでUFOを引っ捕らえる
いいっすよね。UFO。
あの愛らしいフォルムとか、中から何でてくるかわかんないミステリアス感とか…最高やん?でも、皆さん実際に見たことあります?
もしかしたら………ないんじゃないですか?「言われてみれば一度も見たことがない…?」
「小さい頃に見たあれは僕らの妄想なのか…?」
「UFOなんて本当はいないんだ…!!!」と、不安な夜をお過ごしの方も多いと思います。
でも安心してください。
いるんです。
そう。アメリカならね。
※これは FOSS4G Advent Calendar 2019 の19日目の記事です。
todo
- UFOの目撃情報を追う
- データを取得する
- csvに吐き出す
- geocodingする(位置情報を取得する)
- csvを更新
- GeoJSONに吐き出す
- ブラウザの地図上に表示する
UFOの目撃情報を追う
アメリカには国立UFO情報センター(NUFORC)という1974年以来継続的に運用されている、アメリカ全土のUFO目撃情報が集約されたステキなサイトがあります。
すげえなアメリカ。
今回はこちらのデータセットを利用させてもらいましょう。
データはこちらに月ごとにまとめられており、最新のものは12月に2件ほど登録されていますが、今回は月ごとのデータが出揃った11月のものを拝借しましょう。
11/2019をクリックすると以下の様にデータが表形式で格納されていることがわかります。スクレイピングがイージーそうですね。
ちなみに一番古いデータは
西暦209年6月です。すげえな国立UFO情報センター。データを取得する
Pythonでスクレイピングなんて猫でも出来るので、今回はあまり触ったことのないGoでスクレイピングしてみましょう。まずは
$GOPATH/src/github.com/hogehoge以下にディレクトリを作成し、go.modを作成します。これでディレクトリごとにパッケージ管理できるみたい。Goでは
$GOPATH/src/github.com/hogehoge以下にソースコードを配置するのがお作法の様です。mkdir $GOPATH/src/github.com/hogehoge/ufo_view cd $GOPATH/src/github.com/hogehoge/ufo_view go mod init作成したディレクトリに
main.goを作成し、コードを書いていきましょう。
touch main.gomain.gopackage main //スクレイピングしたいURL var targetURL = "http://www.nuforc.org/webreports/ndxe201911.html" func main() { }まず先に必要なパッケージをインポートしておきます。(外部パッケージは
go get github.com/kellydunn/golang-geoの様な感じで事前にダウンロードしてください)import ( "flag" "fmt" "os" geo "github.com/kellydunn/golang-geo" "github.com/PuerkitoBio/goquery" "github.com/gocarina/gocsv" geojson "github.com/paulmach/go.geojson" )
main.goの中にスクレイピングしたデータを格納するための構造体とその配列の型を定義しましょうtype ufoData struct { Date string `csv:"Date"` City string `csv:"City"` State string `csv:"State"` Shape string `csv:"Shape"` Duration string `csv:"Duration"` Summary string `csv:"Summary"` Posted string `csv:"Posted"` Lat float64 `csv:"Lat"` Lng float64 `csv:"Lng"` } type ufoDates []ufoDataスクレイピングするための関数を書きます
ufoDateList := scraping.GetUFO(targetURL) func GetUFO(url string) ufoDates { var ufoDataList ufoDates //URLを読み込んで格納 doc, err := goquery.NewDocument(url) if err != nil { panic(err) } //mapのキーとするためのスライスを定義 dataSample := []string{"Date", "City", "State", "Shape", "Duration", "Summary", "Posted", "Lat", "Lng"} f := 0 //htmlのtrタグを取得 doc.Find("tr").Each(func(index int, s *goquery.Selection) { ufo := make(map[string]string) //子タグ(1レコード)を取得 s.Children().Each(func(i int, c *goquery.Selection) { //タグ内のtextを格納 elements := c.Text() ufo[dataSample[i]] = elements }) //構造体に格納 data := ufoData{ufo["Date"], ufo["City"], ufo["State"], ufo["Shape"], ufo["Duration"], ufo["Summary"], ufo["Posted"], 0, 0} //ヘッダー行を飛ばす if f != 0 { //スライスに格納 ufoDataList = append(ufoDataList, data) } f++ }) return ufoDataList }csvに吐き出す
取得したデータをcsvに吐き出します。
scraping.WriteCSV(afterGeoCodingDataList) func WriteCSV(ufoDateList ufoDates) { file, _ := os.OpenFile("ufo_data.csv", os.O_WRONLY|os.O_CREATE, 0666) defer file.Close() gocsv.MarshalFile(&ufoDateList, file) return }作成されたcsvを見ましょう。
Date,City,State,Shape,Duration,Summary,Posted,Lat,Lng 11/30/19 06:30,New Port Richey,FL,Unknown,Few minutes,I seen something with colorful lights moving strangely. It was pretty low to be an airplane. And the way it moved was definitely not a,12/1/19,0,0 11/30/19 06:00,Plymouth,MA,Light,15 seconds,"Bright light moving in the sky at ASTRONOMICAL speed then disappearing, re-appearing and plummeting to earth.",12/1/19,0,0 11/30/19 05:20,Lytham St Annes (UK/England),,,30-90 seconds,"15 stars moving quickly and silently over St Annes on Sea. ((NUFORC Note: ""Starlink"" satellites. PD))",12/1/19,0,0 ...ちゃんと取得できてる!
geocodingする(位置情報を取得する)
気が付いた方もいるかもれませんが、元データには緯度経度の情報が含まれておらず、Lat,Lngのカラムは0となっています(ufoDataを0に設定しているので)
なので位置情報を付与していきましょう。
緯度経度の情報は入っていませんが、
Cityカラムに都市名が入っていますので、この情報をもとにgeocodingしていきましょう!※geocoding:地名などの情報から緯度経度などの位置情報を付与すること。
geocodingには、思考停止でGCPを使います。
Google Maps PlatformのGeocoding APIを叩きますのでGCPアカウントを作成しておいてください。
***APIを叩きすぎると家庭が崩壊します。不正利用にも気をつけましょう。***
AWSが不正利用され300万円の請求が届いてから免除までの一部始終
New Port Richeyと入力するとこんな感じで緯度経度が取得できます。※こちらから試せます。
このAPIと
golang-geoパッケージを利用してgeocodingを行います。api_keyなんかは
flagパッケージを利用してコマンドライン引数からとるとか、環境変数に入れるなどしましょう。公開すると一生後悔します。
afterGeoCodingDataList := scraping.GeoCoding(ufoDateList) func GeoCoding(ufoDateList ufoDates) ufoDates { var afterGeoCodingDataList ufoDates var GoogleAPIKey = flag.String("a", "default", "api_keyを指定") flag.Parse() g := new(geo.GoogleGeocoder) geo.SetGoogleAPIKey(*GoogleAPIKey) //var data, _ = g.Geocode("New Port Richey") for _, d := range ufoDateList { var data, err = g.Geocode(d.City) if err != nil { d.Lat = 0 d.Lng = 0 afterGeoCodingDataList = append(afterGeoCodingDataList, d) } else { d.Lat = data.Lat() d.Lng = data.Lng() afterGeoCodingDataList = append(afterGeoCodingDataList, d) } } fmt.Println(afterGeoCodingDataList) return afterGeoCodingDataList }csvを更新
APIを叩くたびに課金される、というのは我々下級国民にとって精神的外傷が大きいので、geocodingした情報は先ほど作成したcsvに突っ込んで更新しましょう。
scraping.WriteCSV(afterGeoCodingDataList) func WriteCSV(ufoDateList ufoDates) { file, _ := os.OpenFile("ufo_data.csv", os.O_WRONLY|os.O_CREATE, 0666) defer file.Close() gocsv.MarshalFile(&ufoDateList, file) return }geojsonに吐き出す
GeoJSONとは...
GeoJSONは、JavaScript Object Notation (JSON) を基とした、GISデータを記述するためのフォーマットです(地理空間データ交換フォーマット)。この形式では、Point, LineString, Polygon, MultiPoint, MultiLineString,MultiPolygon,GeometryCollectionをサポートしています。軽量言語であり、Web GISでの利用例が多く見られます。GitHubには、GeoJSONの地図表示機能があり、リポジトリにデータを配置するだけで可視化が可能です。以下では、GeoJSONで点、線、面を記述する手法について解説します。
「GIS実習オープン教材」よりとのことです。
要は位置情報の入ったJSONってことっすね。
scraping.StructToGeojson(ufoDateList) func StructToGeojson(ufoDateList []*ufoData) { fc := geojson.NewFeatureCollection() for _, d := range ufoDateList { g := geojson.NewPointFeature([]float64{d.Lng, d.Lat}) g.Properties["Date"] = d.Date g.Properties["City"] = d.City g.Properties["State"] = d.State g.Properties["Shape"] = d.Shape g.Properties["Duration"] = d.Duration g.Properties["Summary"] = d.Summary g.Properties["Posted"] = d.Posted fc.AddFeature(g) } fcJSON, err := fc.MarshalJSON() if err != nil { panic(err) } file, err := os.OpenFile("ufo_data.json", os.O_WRONLY|os.O_CREATE, 0600) if err != nil { panic(err) } defer file.Close() file.Write(fcJSON) }これで以下の様なGeoJSONが吐き出されたと思います。
(整形してなくてすいません…){"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[-82.7192671,28.2441768]},"properties":{"City":"New Port Richey","Date":"11/30/19 06:30","Duration":"Few minutes","Posted":"12/1/19","Shape":"Unknown","State":"FL","Summary":"I seen something with colorful lights moving strangely. It was pretty low to be an airplane. And the way it moved was definitely not a"}},{"type":"Feature","geometry":{"type":"Point","coordinates":[-70.6672621,41.9584457]},"properties":{"City":"Plymouth","Date":"11/30/19 06:00","Duration":"15 seconds","Posted":"12/1/19","Shape":"Light","State":"MA","Summary":"Bright light moving in the sky at ASTRONOMICAL speed then disappearing, re-appearing and plummeting to earth."}}...ブラウザの地図上に表示する
フロントエンドはよくわからないので、ナニモワカラナイVue.jsを利用します。
# vuecliのインストール npm i -g @vue/cli # vueと言う名前でプロジェクトを作成して移動 vue create vue && cd vue
vue/src/assetsに作成したgeojsonの拡張子を.jsonに変更して入れていきましょう。あと、どっかから適当にUFOの画像でも探してきて同じ場所に保存してください。
ファイルをバシバシ修正、存在しないなら作成していきます。
vue/src/main.tsimport Vue from 'vue' import App from './App.vue' import router from './router' import store from './store' import 'mapbox-gl/dist/mapbox-gl.css' Vue.config.productionTip = false new Vue({ router, store, render: h => h(App) }).$mount('#app')/vue/src/views/About.vue<template> <div class="about"> <MapPane></MapPane> </div> </template> <script> import MapPane from '@/components/MapPane.vue' export default { name: 'about', components: { MapPane } } </script>vue/src/components/MapPane.vue<template> <div class='mapPane'> <div id='map'></div> </div> </template> <script> import mapboxgl from 'mapbox-gl' import ufo_data from '@/assets/ufo_data.json' import ufo_image from '@/assets/ufo.png' export default { name: 'MapPane', mounted: function () { this.mapCreate(); }, methods: { mapCreate: function () { let map = new mapboxgl.Map({ container: "map", style: { "version": 8, "sources": { "OSM": { "type": "raster", "tiles": ["http://a.tile.openstreetmap.org/{z}/{x}/{y}.png"], "tileSize": 256, } }, "layers": [{ "id": "OSM", "type": "raster", "source": "OSM", "minzoom": 0, "maxzoom": 18 }] }, center: [-82.7192671,28.2441768], zoom: 12 }); map.on('load', function () { map.addSource('ufo_point', { type: 'geojson', data: ufo_data }); map.loadImage(ufo_image, function (error, res) { map.addImage('ufo_image', res); }); map.addLayer({ "id": "ufo_point", "type": "symbol", "source": "ufo_point", "layout": { "icon-image": "ufo_image", "icon-allow-overlap": true, "icon-size": 0.08 }, }); }); map.addControl(new mapboxgl.NavigationControl()); } } } </script> <style scoped> #map { z-index: 0; height: 800px; } </style>修正したらローカルでサーバーを起動させていきましょう!
ローカルサーバーを起動
npm run serveサーバーが立ち上がったらブラウザから
http://localhost:8080/に接続してみましょう!UFOいっぱいおる!!!!!!!!!
結論
Goは楽しい
Vue.jsは楽ちん
UFOはかわいい
- 投稿日:2019-12-19T20:09:02+09:00
なぜReactでもVueでもなくElmを使っているのか
はじめに
Leverages(レバレジーズ)Advent Calendar 2019 22日目担当の坂田です。
お仕事では自社メディアである、フリーター、第二新卒向け就職エージェントサイトのシステムや人材情報の管理システムの新規開発、保守運用などをしています。
今回は自社メディアのエントリーフォームを開発するにあたりフロントエンドフレームワークであり、言語でもある
Elmという技術を利用した経緯について書きます。かなりの人がElmに関して知っていることが少なかったりElmという言葉を聞いたことがないかと思われますが、参考程度に聞いてやってみたいと思う人が増えればと思います。
この記事ではElmの技術的なお話や、具体的なお話は一切しません。
結論
Elmはアンチパターンを実装できない。人の目を介さず、人的リソースを使わずにソフトウェア品質の最低ラインを強力な形で担保できるため。なぜReactでもVueでもなくElmを使っているのか
仮定
仮定1. 各コンポーネント等に状態を持たせることは問題である
大規模なアプリケーションは、多くの状態が色々なコンポーネントに散らばったり、コンポーネント間の相互作用のために複雑になりがちです。この問題を解消するために ...
引用元 https://jp.vuejs.org/v2/guide/state-management.html
仮定2. 関数型、型付けは強く、静的であるほどバグが減る
言語のカテゴリ毎の不具合の作りやすさの調査
指向 動的静的 型付けの強さ メモリ管理の有無 低いほど高得点 関数型 静的 強い型付け 有 -0.25 関数型 動的 強い型付け 有 -0.17 手続き型 静的 強い型付け 有 −0.06 スクリプト 動的 強い型付け 有 0.001 スクリプト 動的 弱い型付け 有 0.04 手続き型 静的 弱い型付け 無 0.14 Functional-Static-Strong-Managed −0.25 (0.04) ∗∗∗
Functional-Dynamic-Strong-Managed −0.17 (0.04) ∗∗∗
Proc-Static-Strong-Managed − 0.06 (0.03) ∗
Script-Dynamic-Strong-Managed 0.001 (0.03)
Script-Dynamic-Weak-Managed 0.04 (0.02) ∗
Proc-Static-Weak-Unmanaged 0.14 (0.02) ∗∗∗各カテゴリに属する言語は以下のとおりです。
Functional-Static-Strong-Managed: Haskell、Scala
Functional-Dynamic-Strong-Managed: Clojure、Erlang
Proc-Static-Strong-Managed: C#、Java、Go
Script-Dynamic-Strong-Managed: Python、Ruby
Script-Dynamic-Weak-Managed: Perl、PHP、JavaScript
Proc-Static-Weak-Unmanaged:C、C++、Objective-C引用元 https://developers.srad.jp/story/14/11/08/081210/
但しこの記事の仮定では各言語の具体的なポイント数は考慮しないものとして、カテゴリ毎のポイント数のみ見ることにします。
仮定3. Nullを他の型の一部として扱うのは問題である
2009年のカンファレンスでNull参照を発明したことについて謝罪している。
それは10億ドルにも相当する私の誤りだ。null参照を発明したのは1965年のことだった。当時、私はオブジェクト指向言語 (ALGOL W) における参照のための包括的型システムを設計していた。目標は、コンパイラでの自動チェックで全ての参照が完全に安全であることを保証することだった。しかし、私は単にそれが容易だというだけで、無効な参照を含める誘惑に抵抗できなかった。これは、後に数え切れない過ち、脆弱性、システムクラッシュを引き起こし、過去40年間で10億ドル相当の苦痛と損害を引き起こしたとみられる。
これら上記の3つの仮定に関して、一般的にエンジニアの皆さんには共通した意識があると思います。
仮定1について
仮定1. 各コンポーネント等に状態を持たせることは問題である
ElmというフレームワークはVueで言うところのvuex、ReactでいうところのReduxなどに用いられているアーキテクチャを標準搭載 & それ以外のアーキテクチャを利用できない仕組みになっており、基本的に状態を一元管理することしかできません。
なおvuexとReduxはElmアーキテクチャに触発されたものであり、公式に記述しています
https://jp.vuejs.org/v2/guide/state-management.html
https://github.com/reduxjs/redux
リンクに飛んでブラウザでElmと検索してみましょうそして現在の時刻を取得する関数やhttp通信などの関数は毎度同じ関数とは言えず、状態保有した関数と言えます。
Elmはそれらを関数として使うことができません。(便利に使えて関数とは違う仕組みとして利用することはできます)ReactやVueでElmアーキテクチャを利用していたとしても、JSやTypeScriptの言語仕様として、状態の保持を頑張ればできてしまうんですね。
最大の違いは上記の、状態を保持することができないこと、純粋関数以外を記述できないという点を、言語仕様として厳しくコンパイラがチェックしている点にあります。
その為、コードレビューやコーディング規約などの人的リソースを使う必要なく、品質の高いソフトウェアを記述できるという利点があります。
仮定2について
仮定2. 関数型、型付けは強く、静的であるほどバグが減る
VueとReactは最大でこれらの条件に近づくには、Any型を含み、手続き型であるTypeScriptを利用することがJavaScriptからの精一杯の解決法です。
Elmは関数型であり、強い型付けであり、静的です。
仮定3について
仮定3.Nullを他の型の一部として扱うのは問題である
仮定2に近く見えるかもしれませんが、強い型付けだとしても、Javaのような言語ではNullが存在します。これらは仮定2の論文では分類されていません。その為違う話題として取り上げています。
ElmではJavaScriptとは異なり、Nullが存在しません。Intという型と、IntとNullの直和の型を完全に別のものとして捉えており、問題を解決しています。
なお、TypeScriptでもこの問題は解決しています。
しかしanyがあるのでやはりコードレビューリソースが追加でかかることや書き手によりコードの質が大きく変わってしまうのではという問題が付き纏います。つまり
全体的にElmという言語は開発者やコードレビューの質に依存せず品質を向上させ、ソフトウェアの品質の最大値を上昇させるというものではなく品質の最低ラインを担保するという性質がありそうです。
仮定を真とし、これらの課題を解決することに絞るとするならば、Elmに帰結することは常と言えます。
(ここではReactやVueをディスっているように見えますが、任意の仮定と課題に絞り勝っている部分があるというだけです。)現実的には
しかし、実際にElmを実戦投入するとなるとReactにはあるパッケージがElmにはなかったり、そもそも記法がML系言語だったりと大多数のエンジニアには受け入れがたい現実があります。
パッケージに関してはそこそこ数も揃っている上に、JSと純粋さを保ちつつ連携できる機能がElmには備わっています。
そして慣れてしまえばML系の記法は個人的にはAlgol系よりも書きやすいものですし、DXも非常に高いと思ってはいますが、チームで開発する以上は他の人の慣れも考慮する必要がありますね。
その為、今回新たに開発したElmを利用したプロジェクトに関しては、全てのページをElmで置き換えるようなことはなく、適材適所で特にロジックや非同期が複雑になりがちなページに絞ってElmを利用することにより、チームとして最大限の効果を享受しようと試みています。
(なお、Elmは非常に言語機能がコンパクトかつシンプルに纏まっており、非常に学習コストの低い言語と言えます。)Elmは小さく始めることも可能なのです。
個人的には
存在しないパッケージを自分で開発したりとか、フレームワークや言語にバグが存在すれば自分で治せるし、そんなことよりもそもそも直しようがない言語の構文や機能自体から波及するメリットの優位性を重視して他の言語やフレームワークではなくElmを利用しています。
さらに、最近のオブジェクト指向への懐疑的な流れから流行っているモダンな言語機能をもつRustやScalaなどといった言語と同じような代数的データ型やnullの扱いについても学べ、開発者自身の市場価値や技術力を向上させることも利用した動機の一つになっています。
個別具体的な用法についてのコード他の言語とは異なりネットに転がってないので、コピペして機能開発をすることはできませんが、正しく理解していれば、開発可能性(計算可能性に掛けた今作った言葉)はReactやVueと全く同じなので、ぜひ挑戦してみてください。
まとめ
Elm、はっきり言ってコードレビューの工数減るし、実行時エラー出ないし、新規開発や修正時のバグ出ないし、技術的負債がたまりにくいので競合他社は絶対に使わないでください。
- 投稿日:2019-12-19T18:34:45+09:00
Nuxt + TSのテンプレを作った + 自分なりのおすすめの設定
概要
- Nuxtを1年ぐらい触ってきた
- つくったNuxtのテンプレ紹介するよ
- つくったテンプレ以外におすすめの設定を紹介するよ
前提知識
Nuxtとは、Vue.jsのフレームワーク。
Nuxtは、モダンな web アプリケーションを作成する Vue.js に基づいたプログレッシブフレームワークです。Vue.js 公式ライブラリ(vue、vue-router や vuex)および強力な開発ツール(webpack、Babel や PostCSS)に基づいています。 Nuxt の目標は、優れた開発者エクスペリエンスを念頭に置き、Web 開発を強力かつ高性能にすることです。
SSR(サーバサイドレンダリング)とかの設定が楽だったり、Nuxtのプラグインが充実してたり、他にもいろいろ機能がある。
Nuxtいらない説(vue-cliでよくない)とかあるけど、自分はわりと好き。
QiitaでNuxtがやんわり流行っているのは、「Vueのとっつきやすさ+構築の楽さ+ブログの多さ」が大きな理由だと個人的に感じている。
正直Nuxt自体がVueのテンプレみたいなところもあるし、
npx nuxt-create-appでいろいろ設定できるのでNuxtのテンプレっていう言葉しっくり来てない。
どちらかというと自分なりのNuxtの初期設定っていう話のほうが正しい気がする。作ったNuxtのテンプレの紹介
GitHubに公開してあります↓ スターをつけてもらうと泣いて喜びます。
redshoga/nuxt-ts-gottani-template: Nuxt + Typescript + α project template
主な機能としては以下の通り
- TypeScriptの設定
- 型安全なVuexの構成(
vuex-module-decoratorsを使ってます)- 型安全な環境変数の構成
- SCSSのサポート, Normalize.cssの導入
- Path Autocompleteの設定(
@,~以降でもパスの補完がVSCodeで効く)- ESlintの設定(VSCodeでは保存時に自動フォーマット)
- Stylelintの設定(VSCodeでは保存時に自動フォーマット)
TypeScriptの設定
基本
TypeScript サポート - NuxtJSをやっただけです。
VueはTSにすると以下のどの形で書くかが一番重要な点になると思います。
これをまず先に決めて実装するのがいいと思います。(ここを迷って無駄な時間を浪費した)
Vue.extend({})でJSの時と似たような形 (https://jp.vuejs.org/v2/guide/typescript.html#%E5%9F%BA%E6%9C%AC%E7%9A%84%E3%81%AA%E4%BD%BF%E3%81%84%E6%96%B9)vue-class-componentを使ってクラススタイルVueコンポーネントな形(https://jp.vuejs.org/v2/guide/typescript.html#%E3%82%AF%E3%83%A9%E3%82%B9%E3%82%B9%E3%82%BF%E3%82%A4%E3%83%AB-Vue-%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88)vue-property-decoratorを使ってクラススタイル+デコレータな形(https://github.com/kaorun343/vue-property-decorator)今回紹介しているテンプレは
Vue.extend({})の形で実装しています。Vuex
Vuexの管理は公式のまんま(
vuex-module-decoratorsを使う方法)でやるといいです。ここらへんの実装方法はいろいろあって正直悩みます。Nuxt Typescript公式のものと本で読んだものなど合わせれば5パターンぐらいありそう。チームのスキルとの相性とかも重要だとおもいます。
Sass(.scss)の話
生CSSは辛いので、Sassを導入しています。
css-moduleも考えましたがなるべく素のウェブ構築に近づけたかったため今回は入れていないです。(まだ検討している)実は以下(
style-resources-module)をいれようと考えていました。https://github.com/nuxt-community/style-resources-module
style-resources-moduleを入れるとnuxt.config.jsにグローバルな変数ファイル@import不要で入れることができて便利です。
ただし、@importが不要なので変数がどこから来たものなのかわからなくなったり、コンポーネント自体がNuxtの設定に依存するのであんまりよくないです。使い方は以下のよう
~/assest/sccs/variables.scss$color-main: red;nuxt.config.js... modules: [ '@nuxtjs/style-resources', ], styleResources: { scss: ["~/assest/sccs/variables.scss"], } ...ButtonRectangle.vue<template> <div class="container" @click="..."> </div> </template> <style lang="scss"> .container { /* variables.scssの中身の変数を使える */ background-color: $color-main; } <style>アプリケーションのメインカラーの設定や、z-indexを統合して管理できるのが強い。
けど@importしても同じことができるので、@importしたほうがいいかとというのがここ最近の結論。環境変数の設定
以下の記事をまんまに実装しました。便利便利。
Nuxt.js + TypeScript のアプリケーションで環境変数を安全に管理する - Qiita
最近Nest.js(TSのバックエンドフレームワーク)を触っているのですが、この環境変数の管理もわりとガチガチにやってくれるので以下のような実装もいいのかなって思っています。
内部的にJoiを使って環境変数の検証をしているだけです。Configuration | NestJS - A progressive Node.js web framework
フォーマッタとかの話
Stylelintは早めにいれたほうがいいです。(.vueファイルの中身の
<style>内をフォーマットしてくれるようになります。)
なぜかStylelintを怖がっていたのですが、想像以上に設定は楽なのでおすすめです。またTS,JS,HTML関係はESLint+Veturでフォーマットしています。
TSLintもありますが、TSでもESLintの方が強い気がします。もろもろ。情報も多いですし。
ここら辺の話は自身がないので、お気軽にマサカリ飛ばしてください。設定はVeturを入れて以下の設定を
.vscode/settings.json以下に書くだけです。... "eslint.validate": [ "javascript", { "language": "vue", "autoFix": true } ], "eslint.autoFixOnSave": true, "editor.formatOnSave": false, ...VSCodeを使っているなら、path-autocompleteは入れた方がいいです。(無駄な時間が減らせます)
しかし、Nuxtを使っていると@とか~とかでてきてちゃんと補完してくれません。以下の設定を
.vscode/settings.json以下に書くだけでちゃんと補完してくれるようになります。オススメ。個人的に
@@を使うことはほぼほぼ無いので以下の設定にしています。(YAGNI)"path-autocomplete.pathMappings": { "~": "${folder}", "@": "${folder}" },小話: 将来的に入れたい機能
型安全のHTTPクライアントのaspidaが強そうなので入れたい。
と思っていろいろ調べてたらaspidaの作者様のNuxt+TSのテンプレが出てきた。
小話: JSテンプレ
ちなみに上記のテンプレの元ネタになっているJSのテンプレもあります(TS苦手な人,初学者にはいいかも(?))
redshoga/nuxt-gottani-template: Nuxt project template: various features included
最後に
テンプレを作ったというより、テンプレの作り方みたいな話になっちゃった気がする。
結局のところ、プロジェクトとチームに見合ったものをいれていくといいと思います。(ただしTSへの移行は早めにしたほうがいい)
- 投稿日:2019-12-19T18:08:58+09:00
Vue.jsでmethodsのテストを書く方法
公式とかみてもあんま直接的な情報はなかったので。
複数人で開発していると、本来サーバー側でやってほしい処理などがmethodsとかに入り込みがちですよね。
誰もメンテできなくなる前にしっかりとテストコードを書いておき、いつでもリファクタリングできる状態をキープしておきたいものです。環境
- vue-test-utils
- jest
テスト環境の構築方法などはvue-test-utilsやjestの公式を参考にしてください。
サンプル
こんな感じのかなり尖ったコンポーネントがあるとします。
<template> <button @click="sampleMethod()" /> </template> <script> export default { methods: { sampleMethod() { return 'hello' } } } </script>このコンポーネントの
methodsのテストは下記のように書くことができます。import { createLocalVue, shallowMount } from '@vue/test-utils' import Vuex from 'vuex' import SampleComponent from '~/components/SampleComponent' const localVue = createLocalVue() localVue.use(Vuex) describe('SampleComponent', () => { let wrapper beforeEach(() => { wrapper = shallowMount(UseHighway, { localVue }) }) it('sampleMethodsのテスト', () => { expect(wrapper.vm.sampleMethod).toBe('hello') // 通る }) it('sampleMethodsのテスト(落ちるパターン)', () => { expect(wrapper.vm.sampleMethod).toBe('hey') // NG }) })解説
vue-test-utilsのドキュメントには、
wrapper.vm を使って vm のプロパティとインスタンスメソッドにアクセスできます。これは、Vue コンポーネントのラッパもしくは Vue コンポーネントをバインディングしている HTMLElement のラッパにのみ存在します。
とあります。なので、上記のコードにおいては
wrapper.vmでmethodsを呼び出せるわけですね。
実際には上記のコードのように副作用の全くないコードは珍しく、実際にはsetPropsやsetDataなどで値をセットしてからテストすることになると思います。まとめ
エンジニアとして、やはりフロントエンドにおいてもできるだけ副作用が少なく、テストしやすいコードを書くことを意識していきたいですね。
- 投稿日:2019-12-19T17:24:48+09:00
Vue.jsレベルを上げよう!○×ゲームを作ってTypeScript&Vue3のCompositionAPIと仲良くなる
この記事はVue#2アドベントカレンダーの19日目です。昨日は@mgrさんのVue.jsのcomposition-apiにおける"ref"と"reactive"って何が違うの?でした。今日も引き続きComposition APIに絡む話題(今日は入門向け)です!
前振り
こんにちは、SE→UX→コンサルときて、年明けからは晴れて?フロントエンドの人になる予定の「ゆき」です。
Vue界隈の今年はTypeScriptや来たるVue3に向けたCompositionAPIなど、根幹に関わる(=ちょっと難しい)話題が多い一年だったかと思います。みなさん、TypeScriptは使いこなしてますか?CompositionAPIはもうマスターしたでしょうか?
私は全然です
・・・っていうかこれ超難しくね?
対象読者とこの記事の内容
この記事は私と同じように、「今までの"普通"のVueはチョットワカル
」でも「最近の新しいのはよくわからない
」「むしろ怖い
」人のために、簡単な○×ゲームを作ってTypeScript + CompositionAPIを体験してみる内容です。1
○×ゲームくらいならなんとなくできそうな気がするじゃないですか?流れとしては、
- まずは普通のVue + JavaScriptで○×ゲームを実装
- できるだけ原型を維持しながら、TypeScript + CompositionAPIに書き換え
という順で、TypeScript + CompositionAPIを使うと書き方がどう変わるのか + どこらへんが便利になるのかを、比較しながら実装して親しみを持つことがゴールです。ゆるくお付き合いくださいませ。単に「Vueで○×ゲーム作りたい!」って人ももちろんOK
まずは普通に○×ゲームを作る
仕様と方式
動くもの : https://yuneco.github.io/vue-tic-tok-toe/
ソース一式はこちら : https://github.com/yuneco/vue-tic-tok-toe作りをできる限りシンプルにするため、今回作る○×ゲームは以下の単純な仕様とします:
- 3x3のマスに○と×を交互に置いて、一列揃ったら勝ち。揃わずに全マス埋まってしまったら引き分け(普通のルール)
- ローカルかつ人対人のみ(ネットワーク戦や対PC戦はなし)
- ゲームオーバー後のリセット機能はなし(画面リロードしてね)
また、実装にあたってVueRouterやVuexは使わず、単純なコンポーネントの組み合わせのみで実現します。(Vuexを使うと難易度が上がるので。この記事を最後までやってみたらチャレンジしてもいいかも)
プロジェクトを作る
では実際に「普通のVue」でプロジェクトを作るところから始めましょう。このあたりは知ってる方の方が多いと思うので、適時飛ばしながら見てください。プロジェクト名は
ttt-js(ttt = Tic Tok Toe = ○×ゲームの英語名)としました。
vue create ttt-js設定はデフォルトでも大丈夫だと思いますが、一応カスタム設定で、
Check the features... : ◉ Babel ◉ CSS Pre-processor ◉ Linter/Formatterを選択しました。CSSやLintの話は今日はしないので、お好みで構いません。
プロジェクトができたら、
npm run serveでテンプレの画面が出るところまで確認しましょう。確認できたら、サンプルで入っているHelloWorld等は消してしまってもOKですざっくり設計
- Appコンポーネント ... GameBoardを配置し、勝ち負けやターン等のメッセージを表示する
- GameBoardコンポーネント ... ゲームの状態を管理保持し、GameCellを使って画面に表示する
- GameCellコンポーネント ... ○×の一マスを表示する。データやロジックは持たない
GameCellコンポーネント
まずは一番シンプルなGameCellコンポーネントから作ります。
GameCell.vue<template> <div class="game-cell" @click="$emit('click')" :style="{ backgroundImage: owner ? `url('/imgs/stone_${owner}.svg')` : 'none' }" > </div> </template> <script> export default { props: { owner: { type: String } } } </script> <style lang="scss" scoped> // 略 </style>シンプルなのでほとんど説明不要かと思います。
- マスひとつをdivとして表現
- マスの所有者を示す
ownerプロパティに従い、マスに背景画像を表示 ('p1'...○の画像 / 'p2'...×の画像)- クリックされたら
clickイベントを発行○と×のsvg画像はpublic/imgsディレクトリに入れます。
マスのコンポーネント自身には状態やロジックは持たず、あくまでpropの指定に従い背景画像を表示しているだけのものです。
GameBoardコンポーネント
次にゲームの中心になるGameBoardコンポーネントです。
これもまずはコードを全部乗せてから、要所要所を説明していきます。GameBoard.vue<template> <div class="game-board"> <game-cell class="game-cell" v-for="(cell, index) in cells" :key="index" :owner="cell.owner" @click="placeStone(index)" /> </div> </template> <script> import GameCell from './GameCell' /** Player1を示す定数(画像ファイルの名前に使用するためSymbolにはしていません) */ const P1 = 'p1' /** Player2を示す定数(画像ファイルの名前に使用するためSymbolにはしていません) */ const P2 = 'p2' /** まだ石が置かれていないセル */ const BLANK = undefined /** プレイヤー毎の置かれる石(文字) */ const STONES = { [P1]: '○', [P2]: '×' } /** 一直線に並ぶセルの組み合わせ。8個しかないので列挙 */ const LINES = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6] ] export default { components: { GameCell }, data () { return { cells: Array(9).fill(0).map(_ => ({ owner: BLANK })), // 全てのセルをowner = BLANKで初期化 turn: P1 } }, computed: { /** 勝者(P1 or P2)。未決と引き分けの場合undefined */ winner () { let winnerPName LINES.forEach(line => { const ownersInLine = line.map(index => this.cells[index].owner) const isAllSame = ownersInLine[0] === ownersInLine[1] && ownersInLine[0] === ownersInLine[2] if (isAllSame && ownersInLine[0] !== BLANK) { winnerPName = ownersInLine[0] } }) return winnerPName }, /** ゲームの勝敗が決まるか引き分けになっていればtrue */ isGameEnded () { return this.winner || this.cells.filter(cell => cell.owner === BLANK).length === 0 } }, methods: { /** 指定のセルに現在のプレイヤーの石を置いて、ターンを交換。 * すでにゲームが終了している場合や石が置けないセルの場合、何もしない。 */ placeStone (index) { if (this.isGameEnded) { return } if (this.cells[index].owner !== BLANK) { return } this.cells[index].owner = this.turn if (this.isGameEnded) { // どちらかの勝ち or 引き分け this.$emit('gameEnd', STONES[this.winner]) } else { // ターン交代 this.turn = this.turn === P1 ? P2 : P1 this.$emit('nextTurn', STONES[this.turn]) } } }, mounted () { // 最初のターンを通知するためにイベントを発行 this.$emit('nextTurn', STONES[this.turn]) } } </script> <style lang="scss" scoped> // 略 </style>テンプレート部
まずはテンプレート部分からです。
GameBoard.vue(テンプレ部)<template> <div class="game-board"> <game-cell class="game-cell" v-for="(cell, index) in cells" :key="index" :owner="cell.owner" @click="placeStone(index)" /> </div> </template>先ほど作ったGameCellコンポーネントをv-forで並べています。
このあとのscript部分で出てきますがcellsは9マス分のオーナー情報(そのマスに石を置いたプレイヤーはどちらか?)を格納した配列です。見た目については今回は説明しませんが、この9マスをCSS Flexboxで3x3に配置してボードを表現しています。マスをクリックしたら
placeStoneメソッドが呼ばれることもわかるかと思います。このメソッドについてもscript部分で説明します。script冒頭部(定数宣言等)
次にscript部分を見ていきましょう
GameBoard(script部冒頭)/** Player1を示す定数(画像ファイルの名前に使用するためSymbolにはしていません) */ const P1 = 'p1' /** Player2を示す定数(画像ファイルの名前に使用するためSymbolにはしていません) */ const P2 = 'p2' /** まだ石が置かれていないセル */ const BLANK = undefined /** プレイヤー毎の置かれる石(文字) */ const STONES = { [P1]: '○', [P2]: '×' } /** 一直線に並ぶセルの組み合わせ。8個しかないので列挙 */ const LINES = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6] ]GemeCellコンポーネントのimportは問題ないと思うので飛ばして、その次、定数をいくつか定義しています。よくあるテトリスやボードゲームのサンプルコードだと、「白石を1、黒石を2とする」みたいにコメントをつけてコードにはこの数字をベタ書きしているものも多いですが、できるだけ文字列やマジックナンバーはそのまま使わず、名前をつけてあげると良いと思います。
data部
次にコンポーネントのdata部分です
GameBoard(data部)data () { return { cells: Array(9).fill(0).map(_ => ({ owner: BLANK })), // 全てのセルをowner = BLANKで初期化 turn: P1 } }cellsに9マス分の初期値を設定しています。ちょっと綺麗じゃ無い書き方ですが、これは
GameBoard(script部)cells: [ { owner: BLANK }, { owner: BLANK }, // ... 中略 ... { owner: BLANK } ]と書くのと同じです。
turnには先手としてP1を設定しています。computed部
つぎ、computedいきます
GameBoard(computed部)computed: { /** 勝者(P1 or P2)。未決と引き分けの場合undefined */ winner () { let winnerPName LINES.forEach(line => { const ownersInLine = line.map(index => this.cells[index].owner) const isAllSame = ownersInLine[0] === ownersInLine[1] && ownersInLine[0] === ownersInLine[2] if (isAllSame && ownersInLine[0] !== BLANK) { winnerPName = ownersInLine[0] } }) return winnerPName }winnerは勝者を示す算出プロパティです。
勝敗が決まっている場合にはP1またはP2を、まだ決まっていない・引き分けになった場合はundefinedを返します。
○×ゲームでは現在の盤面さえ与えられれば勝ち負けを容易に求められる2ので、勝ち負け判定はメソッドではなくcomputedにしておくのが良いかと思います。ロジックについては解説しませんが、冒頭の定数で宣言した8個の直線パターンを総当たりでチェックしているだけです。
GameBoard(computed部)isGameEnded () { return this.winner || this.cells.filter(cell => cell.owner === BLANK).length === 0 }isGameEndedはゲームが終了しているかどうかを示すcomputedです。
○×ゲームが終了するのは勝敗が決まった時と全てのマスに石が置かれた時なので、それをそのまま書いています。methods部
次にmethodsです。マスの
@clickで指定したplaceStoneの実装ですGameBoard(methods部)placeStone (index) { if (this.isGameEnded) { return } if (this.cells[index].owner !== BLANK) { return } this.cells[index].owner = this.turn if (this.isGameEnded) { // どちらかの勝ち or 引き分け this.$emit('gameEnd', STONES[this.winner]) } else { // ターン交代 this.turn = this.turn === P1 ? P2 : P1 this.$emit('nextTurn', STONES[this.turn]) } }
indexで指定されたマスに石を置けるかどうか判定し、置ける場合には置いた上でゲームが終了するかチェックしています。終了しなかったらターンを交代します。
ゲーム終了とターン交代それぞれでイベントを発行して親コンポーネントにゲームの状態を通知しています。mounted(ライフサイクルフック)部
最後にライフサイクルフックのmountedです。
GameBoard(ライフサイクルフック部)mounted () { // 最初のターンを通知するためにイベントを発行 this.$emit('nextTurn', STONES[this.turn]) }上で石を置いた時にターン交代をイベント通知していましたが、それだけだとゲーム開始時にどちらの手番かわからないので、最初に一発
nextTurnイベントを発行しておきます。これでゲームの主要な要素はできました。
Appコンポーネント
最後にゲームをAppコンポーネントに配置します。
App.vue<template> <div id="app"> <div class="msg">{{ gameMsg }}</div> <game-board @nextTurn="nextTurn" @gameEnd="gameEnd" /> </div> </template> <script> import GameBoard from '@/components/GameBoard' export default { name: 'app', components: { GameBoard }, data () { return { gameMsg: '' } }, methods: { nextTurn (player) { this.gameMsg = `${player}の番です` }, gameEnd (winner) { this.gameMsg = winner ? `${winner}の勝ちです` : '引き分けです' } } } </script> <style lang="scss"> // 略 </style>これも説明は不要かと思います。
配置したGameBoardコンポーネントからのイベントを拾って画面のメッセージを変えているだけですね。TypeScript + Composition APIで○×ゲーム
さて、ここからが本題です。
この○×ゲームをTypeScript + Composition APIを使って書き直していきます。
今回はあくまで仲良くなるための導入なので「できるだけ元の形を維持したまま」書き直していきたいと思います。プロジェクトの作成
新しくプロジェクトを作りましょう。名前は
ttt-tsにしますvue create ttt-tsマニュアル設定で今度はTypeScriptを有効にします
Check the features... ◉Babel ◉TypeScript <<< ✨NEW! ◉CSS Pre-processor ◉Linter/FormatterTypeScriptをONにすると
? Use class-style component syntax? (Y/n)と聞いてきますが、ここはnで答えてください。class-style component syntaxはComposition API以前に有力だった書き方です。これがダメなわけではないのですが、今回はComposition APIを使いたいのでOFFにします。プロジェクトができたら、続けてComposition APIを利用できるようにします。
Composition APIは本来Vue3で提供予定のものですが、ほとんどの機能をVue2用に提供する@vue/composition-apiというパッケージがあるので、これをインストールして使います。npm i -S @vue/composition-apiパッケージを入れたら、/src/main.tsを開いてComposition APIを読み込みます。
src/main.tsimport Vue from 'vue' import App from './App.vue' import VueCompositionApi from '@vue/composition-api' // ⭐︎追加 Vue.config.productionTip = false Vue.use(VueCompositionApi) // ⭐︎追加 new Vue({ render: h => h(App) }).$mount('#app')準備ができたので、次から標準版と同様にGameCellコンポーネント・Appコンポーネント・GameBoardコンポーネントをTypeScript + Composition APIで書いていきます。(説明しやすいよう、複雑なGameBoardコンポーネントは最後に説明します)
GameCellコンポーネント(TS + Composition API)
まずは一番簡単なGameCellコンポーネントからです。
<template>と<style>は変わらないので、以下<script>部分のみ記載します。GameCell.vue<script lang="ts"> import { createComponent } from '@vue/composition-api' export default createComponent({ props: { owner: { type: String } } }) </script>JavaScript版との差分は3箇所だけです。
- scriptタグに
lang="ts"を追加してTypeScriptとしてコンパイルされるようにするimportでcomposition-apiを読み込むexport defaultするのが素のオブジェクトではなくcreateComponent関数で作ったコンポーネントこの3点は「お約束」だと思っても良いと思います。基本Composition APIで書く全てのコンポーネントで共通です。GameCellコンポーネントはもともとpropsの宣言くらいしかしていないので、これでおしまいです。
Appコンポーネント(TS + Composition API)
次にAppコンポーネントを書き換えましょう。
App.vue<script lang="ts"> import GameBoard from '@/components/GameBoard.vue' import { createComponent, ref } from '@vue/composition-api' export default createComponent({ name: 'app', components: { GameBoard }, setup () { const gameMsg = ref('') const nextTurn = (player: string) => { gameMsg.value = `${player}の番です` } const gameEnd = (winner: string) => { gameMsg.value = winner ? `${winner}の勝ちです` : '引き分けです' } return { gameMsg, nextTurn, gameEnd } } }) </script>先ほど重なる部分は省略します。
標準版にあったdataとmethodsがなくなって、setupという新しい関数の中に移動しています。Composition APIでは従来data・computed・methods・watch等、仕組みの種類によって書く場所が別れていたものを全てsetupという単一の関数に統合しています。
dataにあったgameMsgはref()に、methodsにあったnextTurnとgameEndは普通の関数として、setupの中に宣言されています。そして
setupの最後でこれらをまとめてreturnする、というのが基本的な流れです。returnしたものがテンプレート部分からアクセスできるようになります。
補足 「setup関数ってなんかごちゃごちゃしてて見辛くない?」
私は最初そう思いました。正直今でもちょっと思ってはいます。
仕組みごとに書く場所が決まっていた方が整理されたコンポーネントを書けそうな気がしませんか?なぜsetupにまとめたのでしょうか?「テンプレートは、問題ではなく、技術を分けるだけだ」
“Templates separate technologies, not concerns.”Reactを作ったFacebookの有名な言葉です。
元来VueのSFC(Single File Component = .vueのコンポーネント)もこの課題を解決するものでした(関心や責任でコンポーネントを分割して、その中にhtml・js・cssをセットで書く)。しかしそれでもなお、SFCでは実装の仕組み(computedやdata)ごとに書く場所が決められています。実際にはコンポーネントを十分小さく分割することで多くの混乱は回避できる気もしますが、それでもコンポーネントが複雑になってくると、関連するデータやロジックがdataやcomputed、watch等に散在して依存関係が見えにくくなるのはVueのあるある、だと思います。
Composition APIのsetupはこれを一旦一箇所に全て混ぜられるようにした上で、仕組みではなく問題(関心事)で開発者が自由に再整理できるようにしたものです。
(Composition API RFCより引用。新しい書き方の方が意味的に整理した書き方ができる)実際setup関数の中身は(従来のmethodsやcomputedのような)Vueの特別な魔法が無いただのJavaScript(TypeScript)なので、分割したり別ファイルからimportしたり、JavaScript(TypeScript)でできることは基本的に全て許されます。そして、魔法のない普通のJavaScript(TypeScript)ということは、補完や静的解析等のエディタの恩恵も最大限に享受できるということでもあります。
この記事では「できるだけ元の形を維持して」Composition APIで書き直しているので分割等は行っていませんが、本来であれば(大きなコンポーネントは特に)setup関数をベタ書きせずに分割やimportを使って細かい粒度に切り出していくのが良いのだろうと思います。
蛇足ここまで
(私も理解は十分でないので、間違っていたらご指摘ください)
GameBoardコンポーネント(TS + Composition API)
最後に一番大きなGameBoardコンポーネントです。
これも少し長いですが、まずはscript部分を全部のせます。GameBoard.vue<script lang="ts"> import { createComponent, ref, computed, onMounted } from '@vue/composition-api' import GameCell from './GameCell.vue' // 型・インタフェースの宣言 type Player = 'p1' | 'p2' type Owner = Player | undefined interface CellState { owner: Owner } interface GameState { cells: CellState[], turn: Player } /** Player1を示す定数(画像ファイルの名前に使用するためSymbolにはしていません) */ const P1: Player = 'p1' /** Player2を示す定数(画像ファイルの名前に使用するためSymbolにはしていません) */ const P2: Player = 'p2' /** まだ石が置かれていないセル */ const BLANK: Owner = undefined /** プレイヤー毎の置かれる石(文字) */ const STONES = { [P1]: '○', [P2]: '×' } /** 一直線に並ぶセルの組み合わせ。8個しかないので列挙 */ const LINES = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6] ] export default createComponent({ components: { GameCell }, setup (props, ctx) { // 1. 元 data = reactive(), ref()でラップする const cells = ref<CellState[]>(Array(9).fill(0).map(_ => ({ owner: BLANK }))) const turn = ref<Player>(P1) // 2. 元 computedの要素 = computed()でラップする const winner = computed<Player | undefined>(() => { let winnerPName: Player | undefined LINES.forEach(line => { const ownersInLine = line.map(index => cells.value[index].owner) const isAllSame = ownersInLine[0] === ownersInLine[1] && ownersInLine[0] === ownersInLine[2] if (isAllSame && ownersInLine[0] !== BLANK) { winnerPName = ownersInLine[0] } }) return winnerPName }) const isGameEnded = computed<boolean>(() => { return !!(winner.value || cells.value.filter(cell => cell.owner === BLANK).length === 0) }) // 3. 元 methodsの要素 = そのまま(素の関数) const placeStone = (index: number) => { if (isGameEnded.value) { return } if (cells.value[index].owner !== BLANK) { return } cells.value[index].owner = turn.value if (isGameEnded.value) { // どちらかの勝ち or 引き分け ctx.emit('gameEnd', winner.value ? STONES[winner.value] : undefined) } else { // ターン交代 turn.value = turn.value === P1 ? P2 : P1 ctx.emit('nextTurn', STONES[turn.value]) } } // 4. 元 ライフサイクルフック = on + フック名の関数でラップ onMounted(() => { // 最初のターンを通知するためにイベントを発行 ctx.emit('nextTurn', STONES[turn.value]) }) // 最後にテンプレートから参照される物を全て返す return { cells, turn, winner, isGameEnded, placeStone } } }) </script>ポイントになる箇所を見ていきます。
型・インタフェースの宣言
冒頭で主な型とインタフェースを宣言しています。
JavaScriptでは定数程度でしたが、TypeScriptでは型を使ってより強力・明確に変数の内容を縛れることがわかると思います。これで例えば「現在のターン(turn)にnullが入る」と言ったバグは回避できますね。GameBoard(スクリプト冒頭)// 型・インタフェースの宣言 type Player = 'p1' | 'p2' type Owner = Player | undefined interface CellState { owner: Owner } interface GameState { cells: CellState[], turn: Player }元data部
setup関数の中を見ていきまししょう。
まずは元dataだった部分です。先ほどのAppコンポーネントと同様、ref()を使ってラップすることでリアクティブな変数を作ります。ref()には型を明示することもできます(省略・型推測もできますが、たとえばこの例でturnの<Player>を消すとP1型と解釈されP2が代入できなくなります)。GameBoard(元data部)// 1. 元 data = ref(), reactive()でラップする const cells = ref<CellState[]>(Array(9).fill(0).map(_ => ({ owner: BLANK }))) const turn = ref<Player>(P1)この
ref()でラップした変数の値を参照するときは(ラップされてしまっているので).valueをつけてアンラップする必要があることに留意してください。const num = ref(123) // ラップ(リアクティブな変数になる) console.log(num.value) // アンラップ(中身の普通の値を取り出す) num.value = 456 // 中身を書き換えまた、複数の変数をまとめてリアクティブにしたい場合、refの代わりにreactiveを使うこともできます
// 別解: reactiveを使ってオブジェクトを丸ごとラップすることもできる // この場合は'data'の塊ごとreturnするので、アクセスするときもdata.cells[0]のようになる // (reactive内の要素にアクセスする際は".value"は不要) const data = reactive<GameState>({ cells: Array(9)...略 turn: P1 })元Computed部
続いてComputedです。
これもComposition APIでComputed()というそのまんまの名前の関数が提供されているので、これでラップするだけです。GameBoard(元computed部)const winner = computed<Player | undefined>(() => { let winnerPName: Player | undefined LINES.forEach(line => { const ownersInLine = line.map(index => cells.value[index].owner) const isAllSame = ownersInLine[0] === ownersInLine[1] && ownersInLine[0] === ownersInLine[2] if (isAllSame && ownersInLine[0] !== BLANK) { winnerPName = ownersInLine[0] } }) return winnerPName })ref()の場合と同様、型を明示することもできます。
winnerはPlayer型ですが、勝敗が決まっていない・引き分けの場合はundefinedを返すとしているので、それを明示しています。(Owner型と書いても同じです)isGameEndedも同様です。一点、
!!をつけて戻り値がbooleanになるようにしました。GameBoard(元computed部)const isGameEnded = computed<boolean>(() => { return !!(winner.value || cells.value.filter(cell => cell.owner === BLANK).length === 0) })そう、実は最初に書いたJavaScript版のisGameEndedは戻り値がbooleanじゃなかったんです。。
試しにこの!!と<boolean>を外して関数の戻り値型を見てみると... 実はbooleanの他に文字列も返る実装にになってしまっていることがわかります。

空では無い文字列は真偽値として評価するとtrue扱いになるため大抵のケースでは問題は起こりませんが、その分見つかりにくいバグを埋め込むことにも繋がります。TypeScriptを使うとこういう気づきにくい間違いを事前に拾えるのでありがたい限りです元methods部と$emit
methodsの書き換えはAppコンポーネントでも出てきたので簡単に。
GameBoard(元methods部)// 3. 元 methodsの要素 = そのまま(素の関数) const placeStone = (index: number) => { if (isGameEnded.value) { return } if (cells.value[index].owner !== BLANK) { return } cells.value[index].owner = turn.value if (isGameEnded.value) { // どちらかの勝ち or 引き分け ctx.emit('gameEnd', winner.value ? STONES[winner.value] : undefined) } else { // ターン交代 turn.value = turn.value === P1 ? P2 : P1 ctx.emit('nextTurn', STONES[turn.value]) } }イベントの発行は
setup関数の第二引数として受け取れるctxオブジェクトのemitを使います。setup (props, ctx) {元mounted(ライフサイクルフック)部
次はライフサイクルフックです。
ライフサイクルフックは単純にonXXXという名前の関数がComposition APIで提供されているのでそれを使ってコールバックを登録するだけです。GameBoard(元ライフサイクルフック部)// 4. 元 ライフサイクルフック = on + フック名の関数でラップ onMounted(() => { // 最初のターンを通知するためにイベントを発行 ctx.emit('nextTurn', STONES[turn.value]) })テンプレート部からの利用
setup関数内で作った色々なもののうち、テンプレートから利用するものをsetup関数からreturnします。// 最後にテンプレートから参照される物を全て返す return { cells, turn, winner, isGameEnded, placeStone }これでテンプレートからは従来通りcomputedやmethodを扱えるようになります。
GameBoard(テンプレート部)<game-cell class="game-cell" v-for="(cell, index) in cells" :key="index" :owner="cell.owner" @click="placeStone(index)" />テンプレート部分は今まで通りVueの魔法が効いている部分なので、computedにアクセスするときに
.valueをつけてアンラップする必要はありません。(Vueがやってくれます)
その代わりテンプレートの中では型や補完の恩恵はあまり受けられないことに注意する必要があります。まとめ
この記事では3つのコンポーネントで構成されるシンプルな○×ゲームを従来のJavaScriptの記法と、新しいTypeScript + Composition APIの記法の2つで実装しました。
小規模ゆえに新しい書き方のメリットは理解しにくい部分もあるかと思いますが、これらのメリットをまとめている解説はいくつもあるので、まずはこの記事が新しい書き方にステップアップするための補助輪になれば幸いです。
まとめのまとめ
- Vue3で導入されるComposition APIはVue2でももうほとんど使えるよ
- 従来の書き方の大部分はComposition APIでもそこそこ簡単に書き直せるよ
- TypeScript + Composition APIを使うと補完やエラーチェックが効いて嬉しいよ
- Composition APIではコンポーネント内のロジックの書き方が従来よりも自由に。その分正しい分割や切り出しのスキルも必要になりそう
明日は、@amakawa_ さんです!
- 投稿日:2019-12-19T16:41:47+09:00
Vueで作る汎用的なシンプルアコーディオン
背景
Reactで作る汎用的なシンプルアコーディオン をVueで実装する。
実装
SimpleAccordion.vue<script lang="ts"> import { Component, Vue, Prop } from 'vue-property-decorator' import { VNode } from 'vue' @Component export default class SimpleAccordion extends Vue { @Prop() value!: boolean get display() { return this.value ? 'block' : 'none' } toggle() { const changed = !this.value changed ? this.$emit('open') : this.$emit('close') this.$emit('input', changed) } render(h: any) { return h( 'div', {}, (this.$slots.default as VNode[]).map(node => { if (!node.componentOptions) return null if (node.componentOptions.tag === 'SimpleAccordionSummary') return h( 'div', { on: { click: this.toggle } }, node.componentOptions.children ) if (node.componentOptions.tag === 'SimpleAccordionDetails') return h( 'div', { style: { display: this.display } }, node.componentOptions.children ) }) ) } } </script>SimpleAccordionSummary.vue<script lang="ts"> import { Component, Vue } from 'vue-property-decorator' @Component export default class SimpleAccordionSummary extends Vue {} </script>SimpleAccordionDetails.vue<script lang="ts"> import { Component, Vue } from 'vue-property-decorator' @Component export default class SimpleAccordionDetails extends Vue {} </script>実用例
Sample.vue<template> <SimpleAccordion v-model="show"> <SimpleAccordionSummary> <p>概要</p> {/* ここをクリックすると開いたり閉じたりする */} </SimpleAccordionSummary> <SimpleAccordionDetails> <p>詳細</p> </SimpleAccordionDetails> </SimpleAccordion> </template> <script lang="ts"> import { Component, Vue } from 'vue-property-decorator' @Component export default class Sample extends Vue { show = false } </script>
- 投稿日:2019-12-19T15:47:34+09:00
課題解決手法としてのAtomic Designの解釈と実装
PLAIDでエンジニアをしている @kei-tamiya です。
本記事は PLAID Advent Calendar 2019 の 20日目になります。社外で個人的に開発しているプロジェクトでAtomic Designを採用しており、ある程度知見が溜まったので、1つの運用例としてご紹介したいと思います。
前提
どんなプロジェクト?
- もともとVue.jsを用いて動いているアプリケーションを1からリファクタするプロジェクト
- 別リポジトリに、Nuxt.js, Typescript, Vue Apolloを使用して開発
- フロントエンドエンジニア3〜4人、デザイナー1人
- 全員リモートで、週1で30〜60minぐらいミーティングできる
- GraphQLのAPIサーバーはおよそある状態
- 主に大きなページごと(Userに関するページなど)をエンジニア1人が担当して開発する
もともと課題に感じていたこと・選定理由
もともとそこまで厳格に規約を決めずシンプルにVue.jsで開発をしていましたが、以下のような課題を感じていました。
- 複数のエンジニアがデザインカンプを見て各々の実装をすると、デザインや実装に統一感がなくなる
- 1つのVueファイルが多くの責務を持って肥大化してしまう
- 車輪の再発明しがち・逆に誰かがコンポーネント作るだろうと様子の見合いになって結局作らない/できても中途半端
- デザイナー・エンジニア間の共通言語を作ることで、将来の追加実装や他のプロダクトを開発する際にも応用できる状態にし、スムーズに開発できるようにしたい
そこで、Atomic Designを採用することでデザインや実装方針をある程度画一化し、品質を担保することを目指しました。
Atomic Designとは?
画面を「原子」や「分子」などの単位になぞらえてパーツやコンポーネントとして分割して、それらを組み合わせることで構築するUIデザイン手法です。
以下の記事が詳しいので特に参考にさせていただきました。
本記事では主に、実際にAtomic Designを用いた知見をまとめることとします。
Atomic Designを用いる上で特に意識したこと
結論からいうと、厳密に定義することを追い求めず、あくまで当初の課題を解決することを目的として、以下の点に注意して進めました。
- 完璧なAtomic Designを定義しない・求めない
- AtomとOrganismは先に整備して、基本的にはMoleculeに追加していく
- 肥大化するので、コンポーネントの種類ごとにディレクトリを切る
- 各コンポーネント自体は外側へのマージン情報を持たない・マージンはマージンのみを管理するコンポーネントを作る
完璧なAtomic Designを定義しない・求めない
いざAtomic Designを始めると、どこまでいっても「これはMoleculeじゃなくてOrganismではないか?」など、感覚の議論になりがちです。
当初はそれぞれの粒度を以下のように定義しましたが、言葉の定義だけでは粒度を統一することはエンジニア間でさえかなり難しく、その上でデザイナー・エンジニア間の意思疎通を行うのは無理があるなという感想を抱きました。- Atomsは「それ以上機能が分解できないもの」。 - Moleculesは「ユーザの動作を促すもの」。 // どこまで作ればユーザは動作を促される..? - Organismsは「コンテンツが完結しているもの」。 // 何をもってコンテンツは完結する..?また、コンポーネントを整備する際はAtomから順に作りたくなりますが、実際の実装ではページをどう分割していくかを考えるためPages → Templates → Organisms →...と逆の順で考えることになり、どうしてもOrganismとMoleculeのどちらにするかを決められないケースがあり、もはや悩む時間や、感覚の議論をして手戻りが発生する時間が不毛だなと感じました。
例えば、以下のような場合に迷いが発生しました。小さい単位(Atom)から考える場合
- Atomを組み合わせて作ったListItemはMoleculeに作るとして、ListはOrganismになる..?
大きい単位(Pages)から考える場合
PagesやTemplateは主にレイアウトのコンポーネントになるため、個別のコンポーネントは基本的にOrganismから考えることになり、以下のような迷いが発生しました。
A. 見出しなしのListだけの場合、Listだけで「コンテンツが完結」しているとみなすと、ListがOrganismになる..?
B. 見出し+Listの場合、見出し+Listで初めて「コンテンツが完結」するので、見出し+ListコンポーネントがOrganismsとなるが、Listは完結していないのでMoleculeになる..?
そしてしばらくすると、MoleculeにListがあり、Organismにも見出し+ListコンポーネントとListコンポーネントが氾濫してしまい、それ以降にジョインするエンジニアがどちらに作るべきかさらに迷う状況になってしまいました。
そこで、
諦めてAtomic Designを自分たちが管理可能な形で解釈して、あくまでチームの当初の目的を満たすための設計手法の一つとして捉えることにしました。AtomとOrganismは先に整備して、基本的にはMoleculeに追加していく
MoleculeかOrganismかで悩むぐらいなら、もういっそほとんどをMoleculeにしてしまう決断をしました。
共通のルールとそれぞれの分類ルールは以下のようなルールとしました。共通ルール
- コンポーネントには外へのマージン情報を持たせない
- borderがあるコンポーネントの場合はborderの内側のpaddingはコンポーネントが持っても良い
- 同列単位以下のものはimport可能、自分より大きい単位のコンポーネントはimport不可(e.g. MoleculeからMoleculeのimportは可能、MoleculeからOrganismのimportは不可)
- LoaderやIconなどのAtomを他のAtomからimportするかは悩みポイントで、Loaderは基本importして使うが、Iconをpropsに持つButtonも許した。この辺はそこまで縛らない方針とした。
- 肥大化するので、コンポーネントの種類ごとにディレクトリを切って分類する
├── molecules │ ├── cards │ │ ├── AppCard.vue │ │ ├── AppCardList.vue │ │ ├── FlatCard.vue │ │ ├── index.tsimport時にpathを深くしたくないので、importはindex.tsからのみ行うようにしています。
Atom
- 「それ以上機能が分解できない」という定義である程度分類できていたのでそのまま
Molecule
- 基本的にはAtom, Organismでなければ全部ここに追加する
- 例えば言葉で定義するとしたら、「その種類として完成する以下のものたち」のようなイメージ
- 種類が「Card」のコンポーネントの場合、CardListItem(ループで複数配置する前提のもの)だけでは完成していないので、CardListまでがMolecule
- CardListはCardListItem間のマージンを規定する責務を持つ
- 見出し(種類:Text)+CardList(種類:Card)の場合、別の種類なので見出し+ListコンポーネントはMoleculeには作らない
- 等間隔のマージンを規定するMoleculeをSeparatorとして用意する(これはAtomにすべきか悩ましい)
- 各種Listコンポーネントが任意のSeparatorを用いてListItem間のマージンを規定することで、マージンに関するロジックをSeparatorの責務にできる
├── molecules │ ├── separators │ │ ├── HorizontalMarginSeparator.vue // 水平方向の等間隔マージンを規定する │ │ ├── HorizontalBorderSeparator.vue // 水平方向の等間隔でのborder区切りを規定する │ │ ├── VerticalBorderSeparator.vue // 垂直方向の等間隔マージンを規定する │ │ ├── VerticalMarginSeparator.vue // 垂直方向の等間隔でのborder区切りを規定する │ │ ├── index.tsOrganism
- 「異なる要素間のマージンを規定するもの」
- 粒度は基本的に、HTMLのセクション関連単位(header, footer, nav, sectionなど)
- 一般的にWebの知識がある人同士で共有できる単位の方が意思の疎通がしやすい
例えば、見出しにはAtomのTextコンポーネント(classをTextとする)を使っている場合、AppSectionは直下のTextに対してのみ下方向マージンを与えるようにしました。
<template> <div class="AppSection"> <slot /> </div> </template> <script lang="ts"> import Vue from 'vue'; export default Vue.extend({}); </script> <style lang="postcss" scoped> .AppSection /deep/ > .Text { margin-bottom: 20px; } </style>これにより、AppSectionの中に見出し(Textコンポーネント)+Listコンポーネントを置くことで、異なる要素間のマージンが規定されます。
上述の見出し + Listコンポーネントの場合、以下のようになります。
Template
- Organismをレイアウトする・Organism間のマージンを規定する
2カラムレイアウトや聖杯レイアウトなど、アプリケーションで用いるレイアウトパターンをTemplateとして整備します。
OrganismがHTMLのセクション関連の単位で整備されているので、Organismをどのように配置するかの構造とスタイルを主に書きます。Pages
- Nuxt.jsでいう通常ルーティングをPageとする
- 基本的にはPage内でのみデータアクセス可能
ディレクトリ構成
各ページのドメイン単位でcomponentも用意する
├── src │ ├── components │ │ ├── common │ │ │ ├── atoms │ │ │ ├── molecules │ │ │ ├── organisms │ │ │ └── templates │ │ └── users │ │ ├── molecules │ │ └── organisms │ │ └── templates │ ├── pages │ │ ├── index.vue │ │ └── users │ │ ├── _id │ │ └── index.vue
src/components/common/以下は、どのページからも使う汎用的なコンポーネントを配置しています。
src/pages/users/以下がAtomic Designでいう「Page」にあたり、Userに関するコンポーネントはsrc/components/users/以下に整備することとしました。
Userのページを作る上でも基本的にAtomic Designに則ってディレクトリを分割し、対応するcommon配下のコンポーネントを自由にimportして組み合わせれば基本的にはそこまでCSSを書き足さなくてもpropsに渡すだけで整うような状態を目指しています。実際に使ってみて、どう感じているか
現時点で感じているメリット・デメリットで挙げてみます。
メリット
- 共通コンポーネントを使えばほぼスタイルを書かなくて良いので、似通ったデザインのページの場合は快適に開発できる
- 各ページに閉じるような共通では使わない特殊なコンポーネントの場合は、わざわざ厳密にAtomic Designを追い求めすぎない判断をとりやすくなった
- 新規に整備する共通コンポーネントは基本的にMoleculeに追加するだけなので、判断に迷わなくなった
- デザインカンプをエンジニアが受け取って独自に解釈して実装するしかなかった前と比べると、明らかに共通言語ができて意図を組んだ実装ができる/デザインを作成できるようになった
デメリット
- ある程度満足に開発を進められるレベルにコンポーネントを整備しきるまでにそこそこ時間がかかる
- 実際整備しきった効果を得るのは、今後長い目で複数人で運用していったときだと思うが、早期・初期からリターンを得られるわけではない
- 新規メンバーには定めたルールの一定の学習コストを払ってもらう必要がある
- 最初から完璧に全てのコンポーネントを用意してから開発できるわけではもちろんないので、都度リファクタや整備していくコストをかける必要はある
当初の課題は解決できたのか
学習コストを払う必要はあるものの、ある程度は解決できる状態になったのかと思います。
複数のエンジニアがデザインカンプを見て各々の実装をすると、デザインや実装に統一感がなくなる
→デザインや実装がずれる元凶だったマージンやサイズなどは共通コンポーネント内に閉じているので、基本的に整備した共通コンポーネントを使うことで統一できるようになりました。
1つのVueファイルが多くの責務を持って肥大化してしまう
→共通コンポーネントの粒度に沿って各ドメインもディレクトリ/ファイルを切るので、巨大なVueファイルはかなり減りました。
ただし、Moleculeの中で多くの責務を持ったコンポーネントが居座ってしまいがちなため、定期的に整備する必要があります。車輪の再発明しがち・逆に誰かがコンポーネント作るだろうとお見合いになる
→最初にコストをかけて少人数で重要な共通コンポーネントをおよそ整備しきったので、コンポーネント開発においてコンフリクトはそこまで発生しない状態になりました。
しかしながら、今回のプロジェクトはそこまで複数の複雑な画面があるわけではなかったため、より大規模なプロジェクトだと継続的に上記問題は発生するかもしれません。デザイナー・エンジニア間の共通言語を作ることで、将来の追加実装や他のプロダクトを開発する際にも応用できる状態にし、スムーズに開発できるようにしたい
→エンジニアが実装時に考慮する情報をデザイナーがある程度理解することができたため、実装とデザインの間で以前よりも無理がないよう最初から意識して設計できるような状態に徐々になっていると感じます。
まだ別のプロダクト開発を進めていないので実際に大きな恩恵は受けていませんが、デザイナー・エンジニア間での認識を擦り合せる時間をかなりとったため、共通言語ができたのは大きいです。その他所感
どのように整備を進めると良いか
初期に整備する手順としては、以下の順で進めると良いなと感じました。
- MiroなどでAtom, Moleculeと思うコンポーネントのスクショをできる限り全て洗い出して、関連するものごとに分類する
- どこまで分割するとAtomとみなせるか、という共通認識を擦り合わせておいた方が良い
- 最初は少人数(エンジニア1〜2人・デザイナー1人など)で意思疎通して、マストなコンポーネントから一気にAtom整備を進めた方が良さそう
- Atomの場合、どういう「機能」を持ったAtomか言葉で定義して分類していく
- その機能はさらに分解できないか検討して分割していく
- デザイナーにマージン・ボーダー・文字サイズ・色・hover時の挙動・transition・最大横幅や改行ルールを洗い出し、説明してもらう
- 逆にデザイナーはPropsの概念を理解しづらいので、エンジニアがどういう粒度でコンポーネントを分割しようとしているか思考を説明すると良い
- デザイン的にはA, Bは全く別物だけど、Propsを受けることで1つのコンポーネントでコスパよく(可読性もよく)まとめられるので1つにできるし、したい、みたいな思考を伝えると良い
- 既存ライブラリのコンポーネントを使うことも考える
- Selectなど自前実装するのは骨が折れるコンポーネントも多いので、ライブラリを用いて車輪の再発明をせずに済ませるのも1つの選択肢
- スタイルを上書きすれば当初のデザインを再現できるのかどうか/その実現可能性に沿ってある程度デザインは調整可能かどうかの相談が必要
- ライブラリ選定のタイミングからデザイナーとエンジニアが協力して調査し、デザインガイドラインを一緒に整備するとより良さそう
Storybookは意思疎通の観点では重要
- デザイナー側の理解を促進できる
- デザイナーからの細かいUIの修正依頼を受ける窓口になる(実プロダクトの実装を進めてからの手戻りを減らせる)
- エンジニアのコンポーネント設計・実装をある程度まとめられる
- CardA, CardBの違いを認識するためコードを頑張って追うより、最初にStorybookで視覚的に認識できた方が早い
相応の覚悟とステークホルダーの理解が必要
- デザイナーとエンジニアがお互いを理解するためには、単純なデザインとプログラミングという境界を超えて歩み寄る覚悟が必要です
- 今すぐに恩恵を得られるものではないので、ある程度周囲の理解は必要になるかと思います
おわりに
Atomic Designをチームにとっての課題解決手法として独自に解釈し、使いやすい形で運用している例をご紹介させていただきました。
どの手法を採用したとしても永遠に完璧なものは存在しないので、常にチームにとっての最善を選び、失敗しながらも改善を続けられるのかが大事なのかなと思います。独自の解釈をしているため、「Atomic Design」と銘打つのはおこがましいかもしれませんが、何か参考になる点があれば幸いです。
- 投稿日:2019-12-19T14:21:05+09:00
[Re:ゼロから始めるVue生活] Vueでもっと見るボタンを実装してみた
リストレンダリングというVueの機能を利用して
もっと見るボタンを実装してみました。成果物
データをシンプルにするため、
とりあえずデータは5件、初期表示数は2件にします
また、ユーザー情報と投稿情報を同じ配列に入れます。
(phpのテーブル設計のようにユーザーと投稿を分けて処理させるのがいいかと思いますが
そこまでの技術がないのであくまで仮ということにします)data: { list: [ { id: 1, userName: 'Aさん', thumbnailSrc: './img/ph1.jpg', message: 'メッセージAです', imageSrc: './img/ph1.jpg', created_at: '2019-12-19' }, { id: 2, userName: 'Bさん', thumbnailSrc: './img/ph2.jpg', message: 'メッセージBです', imageSrc: './img/ph2.jpg', created_at: '2019-12-19' }, { id: 3, userName: 'Cさん', thumbnailSrc: './img/ph3.jpg', message: 'メッセージCです', imageSrc: './img/ph3.jpg', created_at: '2019-12-19' }, { id: 4, userName: 'Dさん', thumbnailSrc: './img/ph4.jpg', message: 'メッセージDです', imageSrc: './img/ph4.jpg', created_at: '2019-12-19' }, { id: 5, userName: 'Eさん', thumbnailSrc: './img/ph5.jpg', message: 'メッセージEです', imageSrc: './img/ph5.jpg', created_at: '2019-12-19' }, ], count: 2, },vueの処理
// 中略 computed: { listItems() { // dataのlistプロパティからcountプロパティ設定分に絞る const list = this.list return list.slice(0, this.count) } }, methods: { // buttonをクリックするとcountが増えていく。結果listItemsの数が増えて表示されていく。 isMore() { this.count += 2 } }, // 中略表示
<!--算出プロパティのlistItemsからループ表示--> <div class="list-item" v-for="listItem in listItems" :key="listItem.id" > <img v-if="listItem.imageSrc" :src="listItem.imageSrc" class="posts-image"> <div class="text"> <div class="created_at">{{ listItem.created_at }}</div> <div class="message">{{ listItem.message }}</div> </div> <div class="user"> <img v-if="listItem.thumbnailSrc" :src="listItem.thumbnailSrc" class="user-image"> <span>{{ listItem.userName }}</span> </div> </div> <!--listItemsの数からcount数を引いた時0以上ならボタン表示--> <!--クリックするたびにmethodsのisMoreが発火--> <button class="list-item-button" v-if="(listItems.length - count) >= 0" type="button" @click="isMore" > もっと見る </button>完成形
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> <html lang="ja"> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> <script src="https://cdn.jsdelivr.net/npm/axios@0.17.1/dist/axios.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/lodash@4.17.5/lodash.min.js"></script> <title>Vue App</title> </head> <body> <div id="app"> <div class="contents"> <div class="contents-item"> <div class="list-item" v-for="listItem in listItems" :key="listItem.id" > <img v-if="listItem.imageSrc" :src="listItem.imageSrc" class="posts-image"> <div class="text"> <div class="created_at">{{ listItem.created_at }}</div> <div class="message">{{ listItem.message }}</div> </div> <div class="user"> <img v-if="listItem.thumbnailSrc" :src="listItem.thumbnailSrc" class="user-image"> <span>{{ listItem.userName }}</span> </div> </div> </div> <button class="list-item-button" v-if="(listItems.length - count) >= 0" type="button" @click="isMore" > もっと見る </button> </div> </div> <script> const app = new Vue({ el: '#app', data: { list: [ { id: 1, userName: 'Aさん', thumbnailSrc: './img/ph1.jpg', message: 'メッセージAです', imageSrc: './img/ph1.jpg', created_at: '2019-12-19' }, { id: 2, userName: 'Bさん', thumbnailSrc: './img/ph2.jpg', message: 'メッセージBです', imageSrc: './img/ph2.jpg', created_at: '2019-12-19' }, { id: 3, userName: 'Cさん', thumbnailSrc: './img/ph3.jpg', message: 'メッセージCです', imageSrc: './img/ph3.jpg', created_at: '2019-12-19' }, { id: 4, userName: 'Dさん', thumbnailSrc: './img/ph4.jpg', message: 'メッセージDです', imageSrc: './img/ph4.jpg', created_at: '2019-12-19' }, { id: 5, userName: 'Eさん', thumbnailSrc: './img/ph5.jpg', message: 'メッセージEです', imageSrc: './img/ph5.jpg', created_at: '2019-12-19' }, ], count: 2, }, computed: { listItems() { const list = this.list return list.slice(0, this.count) } }, methods: { isMore() { this.count += 2 } }, }); </script> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { color: #696969; background: #313131; } .contents { margin: 50px; } .contents .contents-item { display: flex; flex-wrap: wrap; justify-content: center; } .contents .contents-item .list-item { width: 300px; margin: 20px 5px 10px; background: #fff; border-radius: 3px; filter: drop-shadow(0 0 2px rgba(0,0,0,0.3)); } .contents .contents-item .list-item .posts-image { width: 100%; height: 150px; object-fit: cover; border-radius: 3px 3px 0 0; vertical-align: bottom; } .contents .contents-item .text { padding: 15px; } .contents .contents-item .text .created_at { font-size: 11px; color: rgb(179, 179, 179); margin: 0 0 5px 0; } .contents .contents-item .text .message { font-size: 13px; font-weight: bold; } .contents .contents-item .list-item .user { padding: 15px; border-top: 1px dashed #ccc; } .contents .contents-item .list-item .user .user-image { width: 30px; height: 30px; object-fit: cover; border-radius: 50%; vertical-align: bottom; } .contents .contents-item .list-item .user span { font-size: 11px; font-weight: bold; bottom: 5px; left: 5px; position: relative; } .contents .list-item-button { display: block; font-size: 14px; color: #ccc; margin: 50px auto; border: 0; background: none; transition: .5s; } .contents .list-item-button:hover { color: rgb(175, 0, 0); } </style> </body> </html>
- 投稿日:2019-12-19T12:48:33+09:00
vue.js 画像をリサイズしてサーバーに保存
画像をリサイズする
https://www.npmjs.com/package/vue-image-upload-resizeスマホからサーバーに画像をアップするとき、やっぱり重い。
5,6秒もかかっちゃうとユーザーが離れちゃう。javascript側で画像をリサイズして、サーバー側でもリサイズすると
画像が荒くなっちゃう気もするが背に腹は変えられない。・vue.js 側で 画像をリサイズ
・base64形式にする
・サーバーに送信
・送信されてきたbase64を画像ファイルにする
・/tmp/ フォルダに保存して返すコピペで動きます。
hoge.vue<style> #fileInput { display: none; } </style> <template> <div> <img :src="kansei"/> {{kansei}} <image-uploader :preview="true" :maxWidth="512" :quality="1.00" :className="['fileinput', { 'fileinput--loaded': hasImage }]" :debug="1" doNotResize="['gif', 'svg']" :autoRotate="true" outputFormat="verbose" accept="video/*,image/*" @input="setImage" > <label for="fileInput" slot="upload-label" style="width: 100%;text-align: center;"> <figure> <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" > <path class="path1" d="M9.5 19c0 3.59 2.91 6.5 6.5 6.5s6.5-2.91 6.5-6.5-2.91-6.5-6.5-6.5-6.5 2.91-6.5 6.5zM30 8h-7c-0.5-2-1-4-3-4h-8c-2 0-2.5 2-3 4h-7c-1.1 0-2 0.9-2 2v18c0 1.1 0.9 2 2 2h28c1.1 0 2-0.9 2-2v-18c0-1.1-0.9-2-2-2zM16 27.875c-4.902 0-8.875-3.973-8.875-8.875s3.973-8.875 8.875-8.875c4.902 0 8.875 3.973 8.875 8.875s-3.973 8.875-8.875 8.875zM30 14h-4v-2h4v2z" ></path> </svg> </figure> <span class="upload-caption">{{ hasImage ? "置き換え" : "タップしてアップロード" }}</span> </label> </image-uploader> </div> </template> <script> import ImageUploader from 'vue-image-upload-resize' Vue.use(ImageUploader); export default { data() { return { hasImage: false, image: null, kansei:'' }; }, methods: { setImage(output) { this.hasImage = true; this.image = output; let dataform = new FormData(); dataform.append('img',output.dataUrl); dataform.append('type',output.info.type); //サーバー側で保存 axios.post('/hoge/test/', dataform).then(e => { this.kansei = e.data.res; }).catch((error) => { console.log("エラー"); }); } } }; </script>で、サーバー側
HogeController.phpclass HogeController extends Controller { public static function test(request $request) { $img = str_replace('data:'.$request->type.';base64,', '', $request->img); $img = str_replace(' ', '+', $img); $fileData = base64_decode($img); $tmp = explode("/",$request->type); $ex = $tmp[1]; if($ex == 'jpeg'){ $ex = 'jpg'; } //saving ここで画像を重ねる $fname = 'test_'.time().'.'.$ex; $path = 'tmp/'.$fname; $file = public_path("/".$path); file_put_contents($file, $fileData); return response()->json(['res'=> '/tmp/'.$fname]); }
- 投稿日:2019-12-19T12:01:17+09:00
vuetify で validation つき form をさっくりつくる
概要
要するによくある入力された段階でエラーがわかる系の UI をつくる話です。
背景
frontend で form に入力された値を validation したいなーという気持になること、多いですよね。
いちいち API に POST してから間違ってるかどうか表示だと若干気分は悪いですから、入力された段階である程度エラーを出してあげたほうが UX としては良いですし、そのようなサイトは増えているように思えます。そこで今回はフォーム要素に入った値を validation するのに Vue.js のフレームワーク Vuetify を使うとめっちゃさっくりできて、ちょっと工夫するとより爆速で作れるようになるよ、というお話です。
Vuetify の form 要素
Vuetify はいろいろなコンポーネントが定義されていますが今回使うのはフォーム要素の
v-text-fieldです。このコンポーネントにはrulesというプロパティがあり validation を行う関数の配列を渡します。渡す配列の各要素は関数となっていて
trueの時が正常な値の場合でなにもしない- それ以外の object がかえされた場合それを文字列評価して error message に表示
ということを
v-text-fieldのほうでやってくれます。これのおかげで error 表示のコードをこちらで書かなくとも validation と間違えた時に出す文字列さえ定義すれば良いのです。便利そうでしょ。
Example 1
たとえば最小構成だと以下のような形式です。若干慣れていないと
simpleRulesが見難いかも知れませんが||はそれより前の項目が false のときにはじめて評価されることを考えるとvが存在していない時に文字列「この項目は必須です」を返すような関数になっていることがわかると思います。<template> <v-container> <v-text-field :rules="simpleRules"/> </v-container> </template> <script> export default { data() { return { simpleRules: [v => !!v || 'この項目は必須です'] } } } </script>Example 2
いくつかの条件を混ぜたいときは、それぞれの条件を配列で渡してあげましょう。
<template> <v-container> <v-text-field :rules="simpleRules"/> <v-text-field :rules="complexRules"/> </v-container> </template> <script> export default { data() { return { simpleRules: [v => !!v || 'この項目は必須です'], complexRules: [v => !!v || 'required', v => (!!v && v.includes('hoge')) || 'must have hoge'] } } } </script>もっと便利に使う
すでに便利そうな匂いがありますが、もうちょっと便利にしていきましょう。
そもそもですがフォームの validation って種類はそんなに無いですよね。よくあるのが必須要素(required)であとはメールアドレスとか、電話番号とか。こういうのはプロジェクトをまたいでも全然問題がない要素たちです。ですので上記のようなルールは別のファイルに定義してしまいます。たとえば
@/utile/rules.jsとかに以下のように書いておきます。// @/utils/rules.js // 色々なルールを export するファイル import validator from 'validator' // email の validation 自分で書きたくないからいれた export default { emailRules: [ v => { if (!v) return true return validator.isEmail(v) || 'Emailが正しくありません' } ], /** * 必須項目のvalidation関数を取得します * @param {String} name 項目名 */ requiredRule: name => v => !!v || `${name}は必須です`, }
validatorを使っているのでこのまま使うときはyarn add validatorとかしてねそしてこの子を global に登録 (inject) してしまいましょう。たとえば Nuxt.js であれば plugins に書くのが自然でしょうか。
// @/plugins/rules.js とかに書く import rules from '@/utils/rules' export default ({ app }, inject) => { // Inject rules to the context as $rules inject('rules', rules) }これによって rules.js で定義されている関数は vue component からであれば
this.$rulesという形で呼び出すことが可能になりました!!これを使うともうちょっと簡単に書くことが出来ます。たとえば必須項目なら
<template> <v-container> <v-text-field :rules="easyRules"/> </v-container> </template> <script> export default { data() { return { easyRules: [this.$rules.requiredRule('このフィールド')], } } } </script>でOKですし, 必須な email なども以下のようにかけます。
<template> <v-container> <v-text-field :rules="emailRules"/> </v-container> </template> <script> export default { data() { return { emailRules: [ this.$rules.requiredRule('email'), ...this.$rules.emailRules ] } } } </script>emailRules はmethodではなくmethodの配列なことに注意して下さい. そのため spread で展開して新しい配列としています.
そしていまさり気なく rules に
thisを使ったの、わかりますか?これを応用すると今の状況に応じて validation を切り替えることが簡単に出来るのです。
たとえばスゴーク簡単な例で言うと form が2つあって一つの form にもうひとつのフォームの値の最小値を入力する用に、とか。<template> <v-container> <v-form ref="form"> <v-row> <v-col> <v-text-field v-model.number="minValue" label="min value"/> <v-text-field :rules="minValueRules" label="好きな値を入力 (over min value)"/> </v-col> </v-row> </v-form> </v-container> </template> <script> export default { data() { minValue: 10 } }, computed: { minValueRules() { return [ v => { if (!this.minValue) return true return v > this.minValue || `${this.minValue}より大きくないと駄目です` } ] } } } </script>Tips
Errorの出るタイミングの変更
rules はデータが変更されたタイミングで都度評価されます。これだとたとえば emailRules などはメールアドレスのタイプ中にエラーがでて UX としてよろしくありません。
このようなときにはフォームのフォーカスが外れたタイミング(i.e. onBlur)で validation をするように指定します。vuetify であれば
validate-on-blurを true にすればOKです。らくちん。<template> <v-container> <v-text-field :rules="emailRules" validate-on-blur/> </v-container> </template> <script> export default { data() { return { emailRules: [ this.$rules.requiredRule('email'), ...this.$rules.emailRules ] } } } </script>Error を一括でクリア
v-formをつかいましょう。v-formにはresetmethod がありこれを呼び出すと自分の slot にある form 要素すべての error message を消去してくれます。らくちん。<template> <v-container> <v-form ref="form"> <v-btn @click="onClickClear">clear</v-btn> <v-text-field :rules="simpleRules"/> <v-text-field :rules="complexRules"/> <v-text-field :rules="easyRules"/> <v-text-field :rules="emailRules" validate-on-blur/> </v-form> </v-container> </template> <script> export default { data() { return { simpleRules: [v => !!v || 'この項目は必須です'], complexRules: [v => !!v || 'required', v => (!!v && v.includes('hoge')) || 'must have hoge'], easyRules: [this.$rules.requiredRule('このフィールド')], emailRules: [ this.$rules.requiredRule('email'), ...this.$rules.emailRules ] } }, methods: { onClickClear() { this.$refs.form.reset() } } } </script>まとめ
Vuetify を使うといい感じに validation ができてエラーメッセージも出せることを紹介しました。場合によっては当然 Vuetify なんかつかえないよーというときもありますが、とりあえず作るが優先度高めのときは有力な選択肢の一つなのかなと思います。
Ref
- Vuetify: Text Field Component: https://vuetifyjs.com/ja/components/text-fields
- 投稿日:2019-12-19T11:23:07+09:00
vue-scrolltoの使い方(基本と別ページ遷移のケース)
はじめに
ページ内の特定のセクションに簡単にスムーズスクロールできるようにする
vue-scrolltoですが、別ページから特定のページのアンカーリンクに飛ばしたい場合にどうすればいいのかまとめました。通常の使い方
npmのインストール
公式はこちら → https://www.npmjs.com/package/vue-scrollto
ターミナルでvue-scrolltoをインストールします。$ yarn add vue-scrolltopluginファイルを作成
/plugins配下にvue-scrollto.jsを作成します。/plugins/vue-scroll.jsimport Vue from 'vue' import VueScrollTo from 'vue-scrollto' Vue.use(VueScrollTo, { /* オプション色々あります。公式サイトから確認できます。 */ duration: 1000, easing: "ease" })nuxt.config.jsに追加
nuxt.config.jsのpluginsのところで上のjsファイルを読み込み。nuxt.config.jsplugins: [ { src: '~/plugins/swiper.js', ssr: false }, { src: '~/plugins/vue-scrollto'} /* こんな感じ */ ],vueファイルにvue-scrolltoを指定
指定のページでリンクの指定をします。これで、リンクをタップすると指定の位置までスクロールしてくれます。
hoge.vuenuxt-link(v-scroll-to="'#aaa'", to) section#aaa p ああああこれだけです
(*^^*)別のページから遷移してきて指定のセクションまでスクロールしたい場合
さっきの例は同一ページでの遷移方法ですが別ページから遷移して指定の位置までスクロールしたい場合もあるかもしれません。そのやり方です。
hogeページの特定のセクションまでスクロールしたい場合、nuxt-linkのto属性に指定をいれます。
hoge.vuenuxt-link(v-scroll-to="'#aaa'", to="/hoge/#aaa") section#aaa p ああああそしてさらに同ページのscriptに下記を追加します。offsetとかは任意です。
hoge.vueexport default { mounted () { if ($nuxt.$route.hash) { this.scrollToHash() } }, methods: { scrollToHash () { const hash=$nuxt.$route.hash this.$nextTick(() => { this.$scrollTo(hash, 0, { offset: -120 }) }) } } };これで、別ページからでも指定のセクションまでスクロールできるようになりました!
参考リンク
https://qiita.com/amishiro/items/f70aca4797cd57bc51b5
https://www.npmjs.com/package/vue-scrollto
https://github.com/rigor789/vue-scrollto/issues/100
- 投稿日:2019-12-19T08:17:20+09:00
Google Apps ScriptでWebアプリケーションをつくる
こんにちは、otkと申します。Qiita初投稿です。よろしくお願いいたします。
はじめに
最近Google Apps Script(GAS)を触る機会があり、今までほとんど触ったことがなかったのですが、使ってみてかなりいろいろできることに驚きました。その中でGASでWebアプリをつくれるということを知ったので、それについて書いていこうと思います。
つくるもの
定番のTodoアプリです。
こちらのチュートリアルのコードを使わせていただきました。(CDN版のVue.jsを使っています。)チュートリアルではデータをローカルストレージに保存していますが、いちばん最後でGoogleスプレッドシートに保存するよう変更していきます。必要なもの
- GoogleアカウントだけあればOKです。
手順
以下手順で進めます。
- GASプロジェクトを作成。
- GASでGETリクエストを受け取り、HTMLを表示するプログラムを書く。
- Webアプリ公開・動作確認。
- CSS、JSを別ファイルに分割してTodoアプリ作成。
- データをGoogleスプレッドシートに保存するように変更。
1. GASプロジェクトを作成。
https://script.google.com/ または Gooleドライブ からGASプロジェクトを作成します。
2. GASでGETリクエストを受け取り、HTMLを表示するプログラムを書く。
サーバー側GASを記述します。
GASでGETリクエストを受け取るようにするためにはHTML ServiceのdoGet関数を使います。以下はGETリクエストを受け取ったときにindex.htmlファイルの内容を表示する処理です。Code.gsfunction doGet() { return HtmlService.createHtmlOutputFromFile('index'); }次にフロント側のHTMLです。
[ファイル] > [New] > [HTMLファイル]でindex.htmlファイルを作成できます。index.html<!DOCTYPE html> <html> <head> <base target="_top"> </head> <body> Hello! </body> </html>3. Webアプリ公開・動作確認。
これだけで最低限の準備ができたので、公開して動作確認します。
[公開] > [ウェブアプリケーションとして導入]。
以下のようにして [Deploy]。
URLが表示されるので、そちらにアクセスすれば
index.htmlの内容が表示されるはずです。これで一応Webアプリを公開することができました。4. CSS、JSを別ファイルに分割する。
Todoアプリを作っていきます。
こちらのチュートリアルの完成形の
index.html,main.css,main.jsを書いていきたいのですが、GASではHTMLファイルとGSファイルのみしか作成できません。ですので<style>タグと<script>タグを使って全てHTMLファイルに記述していきます。ファイルは分割したいので、
main.css.htmlとmain.js.htmlファイルを作成し、それらをindex.htmlファイルで読み込むようにします。main.css.html<style> * { box-sizing: border-box; } #app { max-width: 640px; margin: 0 auto; } /* 〜〜〜 中略 〜〜〜 */ button { border: none; border-radius: 20px; line-height: 24px; padding: 0 8px; background: #0099e4; color: #fff; cursor: pointer; } </style>main.js.html<!-- CDN版Vue.js読み込み --> <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script> <!-- ローカルストレージに保存するための処理 --> <script> // ★STEP2 // https://jp.vuejs.org/v2/examples/todomvc.html var STORAGE_KEY = 'todos-vuejs-demo'; var todoStorage = { fetch: function() { var todos = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); todos.forEach(function(todo, index) { todo.id = index; }); todoStorage.uid = todos.length; return todos; }, save: function(todos) { localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); }, }; </script> <!-- Vueインスタンス --> <script> // ★STEP1 const app = new Vue({ el: '#app', /* 〜〜〜 中略 〜〜〜 */ // ★STEP10 削除の処理 doRemove: function(item) { var index = this.todos.indexOf(item); this.todos.splice(index, 1); }, }, }); </script>
index.htmlで読み込ませるためには以下のように記述します。index.html... <head> ... <?!= HtmlService.createHtmlOutputFromFile('main.css').getContent(); ?> </head> ... <body> <?!= HtmlService.createHtmlOutputFromFile('main.js').getContent(); ?> </body>
<?!=と?>の間にGASのコードを記述して処理を実行することができるので、上のように記述することで別ファイルのmain.css.html,main.js.htmlファイルを読み込むことができます。あとはチュートリアルのコードと同じです。
以上でTodoアプリを作成・デプロイすることができます。簡単!
5. データをGoogleスプレッドシートに保存するように変更。
最後に、ローカルストレージに保存していたデータを、GoogleスプレッドシートをDBのように使ってそちらに保存したいと思います。
以下のようなスプレッドシートを用意します。
こちらをGASで取得する処理は以下になります。
db.gsvar DB_SPREADSHEET_URL = 'スプレッドシートのURL'; var ss = SpreadsheetApp.openByUrl(DB_SPREADSHEET_URL); var sheet = ss.getSheetByName('Todo'); // スプレッドシートからデータを読み込む処理。 function getTodosFromDb() { var lastRow = sheet.getLastRow(); var lastCol = sheet.getLastColumn(); var dataObjects = []; var data2dArray = sheet.getRange(1, 1, lastRow, lastCol).getValues(); var keys = data2dArray[0]; for (var row = 1; row < lastRow; row += 1) { var dataRow = data2dArray[row]; var dataObject = toObject(dataRow, keys); dataObjects.push(dataObject); } return dataObjects; } // スプレッドシートにデータを書き込む処理。 function setTodosToDb(todos) { sheet.clear(); var keys = Object.keys(todos[0]); var data2dArray = [keys]; for (var i = 0; i < todos.length; i += 1) { var dataObject = todos[i]; var dataRow = toArray(dataObject, keys); data2dArray.push(dataRow); } var numRows = data2dArray.length; var numCols = keys.length; sheet.getRange(1, 1, numRows, numCols).setValues(data2dArray); } function toObject(array, keys) { var obj = {}; for (var i = 0; i < keys.length; i += 1) { obj[keys[i]] = array[i]; } return obj; } function toArray(obj, keys) { var array = []; for (var i = 0; i < keys.length; i += 1) { array.push(obj[keys[i]]); } return array; }こちらの
getTodosFromDbメソッドとsetTodosToDbメソッドをフロント側から呼び出したいのですが、それにはgoogle.script.runというAPIを使います。
https://developers.google.com/apps-script/guides/html/reference/run?hl=enこの
google.script.runが少し複雑ですので、以下の例で説明します。例では以下のような処理を行なっています。
- サーバー側で定義されたdoSomethingメソッド(①)をフロント側で
google.script.runで呼び出しています(③)。- doSomethingを実行し、処理が成功するとonSuccessメソッド(②)が実行されます。(失敗した場合はonFailureメソッドが実行されます。)
- このときdoSomethingの戻り値がonSuccessの引数doSomethingReturnValueに渡されます。
GASサーバー側// ① function doSomething(arg) { ... return returnValue; }HTMLフロント側<script> ... // ② function onSuccess(doSomethingReturnValue) { // 何か処理 } ... // ③ google.script.run .withSuccessHandler(onSuccess) .withFailureHandler(onFailure) .doSomething('hello'); ... </script>こんな感じで、このままだと少し使いにくいので、こちらを参考に、async/awaitが使えるように以下の処理を定義します。
HTMLフロント側function scriptRunPromise() { const gs = {}; // google.script.run contains doSomething() methods at runtime. // Object.keys(goog.sscript.run) returns array of method names. const keys = Object.keys(google.script.run); // for each key, i.e. method name... for (let i = 0; i < keys.length; i++) { // assign the function to gs.doSomething() which returns... gs[keys[i]] = (function(key) { // a function which accepts arbitrary args and returns... return function(...args) { // a promise that executes ... return new Promise(function(resolve, reject) { google.script.run .withSuccessHandler(resolve) .withFailureHandler(reject) [key].apply(google.script.run, args); }); }; })(keys[i]); } return gs; }こうすることで以下のような形でサーバー側のdoSomethingメソッドが呼び出せるようになります。
HTMLフロント側var gs = scriptRunPromise(); var doSomethingReturnValue = await gs.doSomething('hello');これを使ってチュートリアルのローカルストレージへのデータ保存部分を以下のように書き換えます。
main.js.html(変更前)<script> var STORAGE_KEY = 'todos-vuejs-demo'; var todoStorage = { fetch: function() { var todos = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); todos.forEach(function(todo, index) { todo.id = index; }); todoStorage.uid = todos.length; return todos; }, save: function(todos) { localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); }, }; </script>⬇️
main.js.html(変更後)<script> var gs = scriptRunPromise(); var db = { fetch: async function() { var todos = await gs.getTodosFromDb(); console.log({ todos }); todos.forEach(function(todo, index) { todo.id = index; }); db.uid = todos.length; return todos; }, save: async function(todos) { await gs.setTodosToDb(todos); }, }; </script>残りは
fetch,saveメソッドの呼び出し部分を修正すればOKです。最終的なコードはこちらです。
おわりに
以上がGASでのWebアプリの作成方法になります。
簡単に、なにより無料で使えるのが便利ですよね。
claspというGASをローカルで開発するためのツールがあるので、もっと凝ったことをしたい場合はこれを使うと便利です。
他にも色々便利な使い方があるようなので、色々やってみて、またアウトプットしていきたいと思います。読んでいただきありがとうございました!
- 投稿日:2019-12-19T04:37:06+09:00
Vueを半年弱触ってた人がReactに手を出して一ヶ月目の所感
この記事の目的
Vue経験者でReactに興味ある人が"やるorやらない"を決めるキッカケになればいいかなと思います。
自己紹介
情報専門学生3年のkazzです。
学校とはまた別で、Web開発のデザイン以外を手広く触っています。最近はもっぱらSPAつくってます(バックNodejs,フロントReact,インフラAWS/EC2)。
ちなみに自分は、ReactもVueも個人制作やハッカソンで使う程度で実務では使ったことがないですし、そこまで精通しているわけでもないので、本記事は「そういうもんなんだな〜」程度で見てくれるとありがたいです。
Reactに移行してみて
移行した経緯
正直、当時のVueの選択理由も学習コストの低さと情報の多さくらいで「まあVueでいいか」みたいな感じだったので、Vueを書き始めて半年弱でやはりReactやAngulerに興味が湧いてきてしまい、JavaScriptもそこそこ書けるようになってきたのもあって、Reactに移行することにしました。
そうです、一番は知的好奇心です。
Reactを選んだ理由
Angulerは大規模開発向きな分ニーズが偏っているそうで、現在の案件数も一番多く将来性もありそうなReactを選択しました。
それに、Angulerは小中規模のアプリを開発するにしてはオーバーヘッドが大きく向いていないそうですし、自分的には違うかなと感じました。
VueからReactに移行して良かったこと、悪かったこと
これは、正直まだ分かりません...。というか、これは設計思想やチーム開発などで得られる恩恵が大きいような気もするので、この段階で分からないのは当然なのかもしれません。
実際Vueのときと比べ、Reactだから出来たとか出来ないだとかは特にありませんし、どちらも日本語ドキュメントが用意されていますし(※19年12月時点でReactは一部和訳されていない部分があるが、そんなに支障はなかった)、ライブラリも豊富に存在します。
難易度、学習コスト
現段階では、どちらもさほど変わらないかなという印象です。
ただ、VueのSFCのような直感的な分かりやすさはなく、少し慣れが必要かなと感じました。
あと、Vue以上にES6の理解は必要です。公式のチュートリアルにも「ES6 という JavaScript の最近のバージョンからいくつかの機能を使用していることにも注意してください」と書かれています。
最低でもconst,letや分割代入、スプレッド演算子、アロー関数くらいはやっておくとスムーズに学習できると思います。
実際に触ってみよう
環境構築(Vue環境有前提)
Nodejsの環境は出来上がっていると思うので、さくっとプロジェクトを作成しましょう。
まず、npmを使わずnpxコマンドからcreate-react-appを実行し、プロジェクトの雛形を作成します。
npx create-react-app sample-appとりあえず
npm run startでReactアプリケーションを起動してみましょう。
さらに、ディレクトリを少し整頓して、src/App.jsを最小構成にしてみましょう。
Vueとの比較
せっかくなのでReactの一部機能をVueと比較しながら紹介します。
SFCとJSX
ReactではJSXという記法を使いHTMLを表現します。Vueでいう単一ファイルコンポーネントのtemplateみたいな感じです。
JSX構文を使えばHTMLをJavaScriptにそのまま書くことができ、更には変数に代入し利用することも可能です。
Vueの場合
App.vue<template> <div id="App"> Hello, OthloTech! </div> </template> <script> export default { name: "app" }; </script> <style> .App { margin: 200px; } </style>Reactの場合
App.jsimport React from 'react'; import "./App.css"; // <- CSSファイル適用 const App = () => { return ( <div className="App"> Hello, OthloTech! </div> ); } export default App;App.css.App { margin: 200px; }下は少しお待ちを...
いいねが付けば付くほど急ぎます
- ### StateとProps
- ### Router
学習方法
UdemyのReactレッスンでざっくり理解
↓
本や検索で知識を固めつつコーディング
↓
Reactの公式ドキュメントを見ながらリファクタリングReact公式ドキュメント:
https://ja.reactjs.org/docs/getting-started.html
- 投稿日:2019-12-19T04:37:06+09:00
Vueを半年弱触ってた人がReactに移行して一ヶ月目の所感
この記事の目的
Vue経験者でReactに興味ある人が"やるorやらない"を決めるキッカケになればいいかなと思います。
自己紹介
情報専門学生3年のkazzです。
学校とはまた別で、Web開発のデザイン以外を手広く触っています。最近はもっぱらSPAつくってます(バックNodejs,フロントReact,インフラAWS/EC2)。
ちなみに自分は、ReactもVueも個人制作やハッカソンで使う程度で実務では使ったことがないですし、そこまで精通しているわけでもないので、本記事は「そういうもんなんだな〜」程度で見てくれるとありがたいです。
Reactに移行してみて
移行した経緯
正直、当時のVueの選択理由も学習コストの低さと情報の多さくらいで「まあVueでいいか」みたいな感じだったので、Vueを書き始めて半年弱でやはりReactやAngulerに興味が湧いてきてしまい、JavaScriptもそこそこ書けるようになってきたのもあって、Reactに移行することにしました。
そうです、一番は知的好奇心です。
Reactを選んだ理由
Angulerは大規模開発向きな分ニーズが偏っているそうで、現在の案件数も一番多く将来性もありそうなReactを選択しました。
それに、Angulerは小中規模のアプリを開発するにしてはオーバーヘッドが大きく向いていないそうですし、自分的には違うかなと感じました。
VueからReactに移行して良かったこと、悪かったこと
Vueだとtemplateが制御構文などで肥大化してネストが凄いことになることがありましたが、Reactはわざわざ別ファイル化せずともコンポーネントの一部を抽象化して外にかけたり、jsの制御構文をそのまま使えたりと、汎用性の高さを感じました。
参考: 条件付きレンダー
実際Vueのときと比べ、Reactだから出来たとか出来ないだとかは特にありませんし、どちらも日本語ドキュメントが用意されていますし(※19年12月時点でReactは一部和訳されていない部分があるが、そんなに支障はなかった)、ライブラリも豊富に存在します。
難易度、学習コスト
現段階では、どちらもさほど変わらないかなという印象です。
ただ、VueのSFCのような直感的な分かりやすさはなく、少し慣れが必要かなと感じました。
あと、Vue以上にES6の理解は必要です。公式のチュートリアルにも「ES6 という JavaScript の最近のバージョンからいくつかの機能を使用していることにも注意してください」と書かれています。
最低でもconst,letや分割代入、スプレッド演算子、アロー関数くらいはやっておくとスムーズに学習できると思います。
実際に触ってみよう
環境構築(Vue環境有前提)
Nodejsの環境は出来上がっていると思うので、さくっとプロジェクトを作成しましょう。
まず、npmを使わずnpxコマンドからcreate-react-appを実行し、プロジェクトの雛形を作成します。
npx create-react-app sample-app # TypeScriptを導入する場合は↓で実行 # npx create-react-app sample-app --typescriptとりあえず
npm run startでReactアプリケーションを起動してみましょう。
さらに、ディレクトリを少し整頓して、src/App.jsを最小構成にしてみましょう。
Vueとの比較
せっかくなのでReactの一部機能をVueと比較しながら紹介します。
Template→JSX
ReactではJSXという記法を使いHTMLを表現します。Vueでいう単一ファイルコンポーネントのtemplateみたいな感じです。
JSX構文を使えばHTMLをJavaScriptにそのまま書くことができ、更には変数に代入し利用することも可能です。View内で変数を使い場合は
{}で囲めばその中は式として判定されます。Vueの場合
App.vue<template> <div class="App"> Hello, OthloTech! </div> </template> <script> export default { }; </script> <style scoped> .App { margin: 200px; } </style>Reactの場合
App.jsimport React from 'react'; import "./App.css"; // <- CSSファイル適用 const App = () => { return ( <div className="App"> Hello, OthloTech! </div> ); } export default App;App.css.App { margin: 200px; }State
VueでいうdataがReactのStateにあたります。コンポーネント内の情報で保持する変数です。
Vueはdataの中身を直接再代入すればViewにも反映されますが、Reactはそうはいきません。State定義時にあわせてセッター関数も定義し、そのセッター経由でデータの変更を行います。
次の例はよくあるカウントアップアプリを実装しています。
Vueの場合
App.vue<template> <div> <div>{{count}}</div> <button @click="count += 1">PLUS</button> </div> </template> <script> export default { data: () => ({ count: 0 }) }; </script>Reactの場合
App.jsimport React from 'react'; const App = () => { // getter setter 初期値 // ↓ ↓ ↓ const [count, setCount] = React.useState(0); return ( <div> <div>{count}</div> <button onClick={() => { setCount(count + 1) }}>PLUS</button> </div> ); } export default App;Props
おなじみpropsです。
Vueの場合
SampleComponent.vue<template> <div> <h2>{{title}}</h2> <div>{{content}}</div> </div> </template> <script> export default { props: { title: String, content: String } }; </script>App.vue<template> <div> <SampleComponent title="Title!1" content="Content!1" /> <SampleComponent title="Title!2" content="Content!2" /> <SampleComponent title="Title!3" content="Content!3" /> </div> </template> <script> import SampleComponent from "./components/SampleComponent.vue"; export default { data: () => ({ count: 0 }), components: { SampleComponent } }; </script>Reactの場合
SampleComponent.jsimport React from 'react'; const SampleComponent = (props) => { return ( <div> <h2>{props.title}</h2> <div>{props.content}</div> </div> ); } export default SampleComponent;App.jsimport React from 'react'; import SampleComponent from './components/SampleComponent'; const App = () => { return ( <div> <SampleComponent title="Title!1" content="Content!1" /> <SampleComponent title="Title!2" content="Content!2" /> <SampleComponent title="Title!3" content="Content!3" /> </div> ); } export default App;API通信(Axips)
こちらはVueは省略します。
useEffectはコンポーネントのレンダリング後に走らせたい処理をコールバックで渡します。その中でAxiosを使ってpokeAPIからデータを取得し、そのレスポンスをstateのセッター経由でviewに反映させます。
以下の例ではpokeAPIからポケモンの図鑑No1〜10のデータを取って、一覧表示させています。
実行結果
Reactコード
App.jsimport React from 'react'; import Axios from "axios"; const App = () => { const [pokemons, setPokemons] = React.useState(null); const [isApiConFailed, setIsApiConFailed] = React.useState(false); React.useEffect(() => { Axios.get("https://pokeapi.co/api/v2/pokemon?offset=0&limit=10").then(response => { setIsApiConFailed(false); setPokemons(response.data.results); }).catch(error => { setIsApiConFailed(true); }); }, []); const ApiResult = ( pokemons !== null && ( pokemons.map((pokemon, index) => ( <div key={index}> <h2>{pokemon.name}</h2> <div> <a href={pokemon.url}>このポケモンの詳細</a> </div> </div> )) ) ); return ( <div> {ApiResult} {isApiConFailed && <div>API通信エラー</div>} </div> ); } export default App;学習方法
1.ハローワールド
2.UdemyのReactコースや公式ドキュメントを参考に色んな機能を試す
3.Vueで作りかけだったものをReactで書き直すReact公式ドキュメント:
https://ja.reactjs.org/docs/getting-started.html
- 投稿日:2019-12-19T02:46:15+09:00
[約束のネバーランド] Vue.jsでミネルヴァさんのモールススタンプを描いてみる
私が今年最もハマった漫画「約束のネバーランド」で登場する、ミネルヴァさんのモールススタンプをVue.jsで作ってみました。
まずは完成したものをどうぞ…
こちらで実際に遊べます
いらすとやさんのおかげもあって、なんだか本家よりもファンシーになっていると思います。コードの解説
https://github.com/minojiro/minerva-morse
まず、スタンプのSVGはVueのコンポーネントになっていて、propでメッセージを渡すとスタンプのSVGが作られます。
こんな感じです。
<MorseStamp message="sos" />それでは、MorseStampコンポーネントの中を解説していきます。
※ 解説のために一部コードを変更したりしています。メッセージのモールス化
morseというnpmパッケージを使っています。
アルファベットの文字列を簡単にモールス化してくれます。const morse = require('morse') console.log(morse.encode('SOS')) // '... --- ...'こんな感じで使えます。ありがたいですね。
モールスの描画
コミックスの3巻でいくつか出てくるモールススタンプを見てみると、どうやらこんな感じのルールで描かれているようです。
- 左上から時計回りに一周して読む
- 1つの英単語がしっかり一周(360度)に収まっている
- モールスで
.と-があるうちで、.は決まった長さ、-は余った長さを均等に割った長さまずは
morseから取得したモールスの文字列を配列にして、-の長さを計算します。const DOT_AND_SPACE_WIDTH = 9 // `.` と ` ` の長さ const morseString = morse.encode(this.message) const chars = `${morseString} `.split('') const barCount = chars.filter(sign => sign === '-').length const dotAndSpaceCount = chars.length - barCount const barWidth = (360 - DOT_AND_SPACE_WIDTH * dotAndSpaceCount) / barCount // ← `-` の長さ!あとはそれぞれのモールス符号をSVGで描画するときのパラメータ計算していきます。
const DOT_AND_SPACE_WIDTH = 9 // `.` と ` ` の幅 const MORSE_HEAD_DEGREE = -135 // 左斜め上から時計回りに読むらしい let previousEndDegree = MORSE_HEAD_DEGREE return chars.map(char => { const charWidth = char === '-' ? barWidth : DOT_AND_SPACE_WIDTH const startDegree = previousEndDegree const endDegree = startDegree + charWidth previousEndDegree = endDegree if (char === ' ') { return '' } // pathタグのd属性の値を取得 return getPathTagDAttrValue(startDegree, endDegree) }).filter(s => s !== '')モールス符号の開始位置と終了位置の度数を受け取って、SVGの
<path>のパラメーター(d属性)を返すところconst CHAR_OFFSET = 2 // 符号ごとの間隔 const degreeToRadian = (degree) => degree * Math.PI / 180 const getPathTagDAttrValue = (degree1, degree2) => { const radian1 = degreeToRadian(degree1 + CHAR_OFFSET) const radian2 = degreeToRadian(degree2 - CHAR_OFFSET) const x1 = Math.sin(radian1) * R + O const y1 = Math.cos(radian1) * R + O const x2 = Math.sin(radian2) * R + O const y2 = Math.cos(radian2) * R + O const f1 = (degree2 - degree1) >= 180 ? 1 : 0 return `M ${y1},${x1} A ${R} ${R} 0 ${f1} 1 ${y2},${x2}` }ここまでできたら、あとはv-forでまわすだけ!
<template> <svg> <path :d="signPath" :key="signPath" v-for="signPath in signPathes" /> </svg> </template>おまけに背景の色と、ふくろうの画像をつけます
<rect fill="#f6eace" /> <image xlink:href="@/assets/animal_fukurou.png" />Vue.js と SVG
今年はなにかとVue.jsでSVGを書く機会が多かったのですが、この2つはかなり相性が良いなと思っています。
慣れ親しんだSVGにちょっとVue.jsのコードを書き加えるだけで気軽に動的なSVGが描画できますし、他のSVG描画系のライブラリを学ぶよりも学習コストが低いのも嬉しいですね。
アニメーションに関してはD3.jsなどの方が得意ですが、条件によって瞬時に描画が変わる簡単なグラフなどは、Vue.jsの方がシンプルに書けそうです。最後に
2020年は、約束のネバーランドのアニメの二期もあるとのことなので楽しみですね!
それではみなさん、(merry christmas)
- 投稿日:2019-12-19T02:07:14+09:00
VueComponentの切り方
この記事はAizu Advent Calendar 2019 の16日目の記事です。
Vue.jsでWebアプリを作ってる際、どう設計していいか迷うことがあるので、その際参考にした記事を自分なりに備忘録としてまとめようと思います。
コンポーネントってどう分ければいいんだよ!!
Vue.jsを使ってSPAを作ろうと早くも何ヶ月も経ってしまった...
だってどこでコンポーネント分けていいかわかんないんだもん!
そんなこんなで開発が止まってしまうことがよくあります。ありますよね?!ね??
なぜVueComponentの設計が難しいか
従来のMVVMと違う
- VueComponentはView+ViewModelを持っている
本来ViewはViewModelからデータを受け取るため、View同士(もしくはViewModel同士)でデータを受け渡すことを想定していない。そのためつなぎ目を意識しなくてよいが、VueComponentの設計を考えないとデータの受け渡しなどに苦労する。
- Modelについての仕組みやルールがない
そのためVuex等の状態管理ライブラリを使う。
しかし理解してやらないと状態管理がめちゃくちゃになる。今回はModelやVuexの取り扱いには触れず、どのようにコンポーネントを分ければいいかにだけ触れます。
VueComponentの切り方
責務によって切る!!
*参考記事にはContainerの責務と5つのComponentの責務がありますが、今回は自分に必要そうなContainerの責務と3つのComponentの責務を書きます。
「Container」の責務
「Component」の責務
- 「ViewModel」の責務
- Templateが肥大化したときに分割されつくられる「View」の責務
- 「汎用UIパーツ」としての責務
「Container」の責務
*Containerは1画面につき1つ
1. ページの構成を表す
- 画面内に配置するComponentを定義する
- 画面全体で必要なModelやStateを管理する
- 画面内で共通の処理を定義する
- Component間のイベントバスになる
2. 他ページとの関係を表す
- 画面同士でデータや状態のやりとりを行う
- ルーティング処理を定義する
- vue-routerがurlと紐付けるコンポーネントとなる
「Component」の責務
「ViewModel」の責務
VueComponentはView(<template>部分)+ViewModel(<Script>部分)を持つ。
なので、ViewModelの役割を過不足なく実装されているとViewModelの責務を持っている。ViewModelの役割とは
https://aloerina01.github.io/blog/2018-05-01-1
・methodsにコンポーネントを操作するロジックを持つ
・data, computedにコンポーネントのStateを定義する
・data, computedにコンポーネントに必要なModelを定義し、リアクティブに更新する
・Templateにdataやcomputedで定義した値を反映する、またv-model等でTemplateの変更を検知するViewModelComponentはそれ単体で1つの機能を担っている。
Component名がその機能を表しているか確認する。「View」の責務
View ComponentはReactのようにステートレスなコンポーネントで、親コンポーネントから受け取るpropsのみで動作させる。
そのため独自のViewのみを持ちViewModelを持たないComponentである。
ロジックを実装したり過剰に機能を持たせたりしないように注意する必要がある。
「Design」の責務
実装の仕方は「View」の責務と同じである。
違う点はUIパーツのデザイン共通化を目的に作られること。最後に
わかりづらい部分があると思いますがお許しください。アウトプットってむずかしい...
まだまだVueだけでなくエンジニアとしても?なのでこれからも学び続けたいです。
ご指摘大歓迎です。
参考
メンテナンスしやすいVueComponentを設計するために気をつけていること
Vue+VuexでMVVMなWebApplicationを設計するときに考えたいこと
- 投稿日:2019-12-19T01:26:22+09:00
Vue.jsでのボタン制御の方法
はじめに
これはVue.jsに触り始めて間もない私が、自分で書いたコードを公式ドキュメントを参考に修正するといった内容になります。
そのため、私と同じようにVue.jsに触り始めた方の参考になれば幸いです。具体的な内容
先日Vue.js+firebaseでSPAを作成したのですが、Vueを学び始めて数日だったので、ボタン制御の部分で誤った実装をしてしまっていました。
その時はこれでいいやで済ませたのですが、公式ガイドを読んでいる際に正しい実装方法が載っていたので、これをもとにコードを修正しようと思います。動作のイメージ
今回の対象は入力フォームに何も入力されていなかった場合にボタンが非活性になるという単純なものです。
元のコード
before.vue<template> <div class="create"> <div style="margin-right: 50px;text-align: right;"> <header>NumG</header> <router-link to="/">Home</router-link> </div> <h1>Create</h1> <div> <p> <br />伝えたい言葉を入力してください。 <br />※個人情報などは入力しないでください。 </p> </div> <div> <form @submit="checkForm"> <textarea cols="30" rows="5" :value="message" @input="doUpdate">いつもありがとう! これからもよろしくね!</textarea> </form> </div> <div> <router-link id="confirmBtn" to="/Confirm" tag="button" class="btn btn-outline-primary" >Confirm</router-link> </div> </div> </template> <script> export default { name: "Create", computed: { message() { return this.$store.getters.message; } }, watch: { message: function() { let el = document.getElementById("confirmBtn"); if (this.message.length) { el.removeAttribute("disabled", "disabled"); } else { el.setAttribute("disabled", "disabled"); } } }, methods: { doUpdate(event) { this.$store.dispatch("doUpdate", event.target.value); }, checkForm() { if (this.message) { return true; } this.error = null; if (!this.message) { this.error = "伝えたい言葉を入力してください"; } event.preventDefault(); } } }; </script>元のコードでは
router-link(送信ボタン)に属性disabledをつけるためにwatchを使い、message(入力値)が変更になるたびに直接DOMの操作を行っていました。あまりVueっぽくない書き方だなぁと思いつつも他に書き方が分からず、時間もないためスルーしてしまいました。
ちなみにmethodsのcheckFormは消し忘れた不要なコードです。。。
上記のwatchのおかげで入力値がない時はそもそもボタンが非活性となるため、ボタンが押せずイベントが発火しません。ドキュメントの該当箇所
ドキュメントの該当箇所はテンプレート構文#属性の部分です。
<button v-bind:disabled="isButtonDisabled">Button</button>
isButtonDisabledがnull、undefined、またはfalseの値を持つ場合、disabled属性は描画された<button>要素に含められません。つまり、
v-bindを使えばisButtonDisabledの属性を自動で制御(付けたり外したり)できるということです。修正したコード
before.vue<template> <div class="create"> <div style="margin-right: 50px;text-align: right;"> <header>NumG</header> <router-link to="/">Home</router-link> </div> <h1>Create</h1> <div> <p> <br />伝えたい言葉を入力してください。 <br />※個人情報などは入力しないでください。 </p> </div> <div> <form> <textarea cols="30" rows="5" :value="message" @input="doUpdate"></textarea> </form> </div> <div> <router-link to="/Confirm" tag="button" class="btn btn-outline-primary" :disabled="isButtonDisabled" >Confirm</router-link> </div> </div> </template> <script> export default { name: "Create", computed: { message() { return this.$store.getters.message; }, isButtonDisabled() { return this.$store.getters.message.length <= 0; } }, methods: { doUpdate(event) { this.$store.dispatch("doUpdate", event.target.value); } } }; </script>以下のように修正しました。
①computedにボタンの押下可否を判定するための算出プロパティisButtonDisabled()を追加
※入力値の文字数が0以下の場合にtrue(押下不可)を返すようになっています。
②router-linkに:disabled="isButtonDisabled"を追加
※念のため補足ですが:はv-bind:を省略した記法です。
②不要になったwatchの削除
④その他の不要庵部分の削除(formの@submit="checkForm"、textareaの無駄な例、router-linkの使わなくなったid、methodsのcheckForm())
修正前と比較するとscriptタグの中がかなりすっきりしたてVueっぽくなったと思います!
特にDOMの要素を直接操作する処理が消えたのが大きいかなと思います。
※動作自体は修正前と変わらないので割愛します。まとめ
公式のドキュメントはちゃんと目を通した方がいいことが分かったので、時間を作って一通り目を通したいと思います。
もっといい書き方があればコメントいただければ幸いです。
- 投稿日:2019-12-19T01:06:21+09:00
stylusのsin,cos関数を使ってうんちたちをウェーブさせてみた
sinとcos
stylusではビルドイン関数が用意されている。
そのなかで、これ使い所ある?みたいなものもありますね?
その代表がsin(),cos()じゃないでしょうか。
sin(),cos()って聞くと波が思いつくので、波の形を作りたい時に使えそうです。
というわけでやってみます!Let's Try!
今回はちょっとコードが長くなるのでhtmlはpugを使います。
上のボタンを押してもらうとうんちたちが波の形に動きます?
ボタンを押した時クラスを付けたかったので、そこはjsも書きました。
普通に書くとめんどくさいのでvueでサクッと書いてます。See the Pen sin by petapetapeta (@petapetapeta) on CodePen.
ポイント
pug
pug(html)ではいっぱい同じような要素を作り、連番のクラスを振ります。
ここはvueでループさせてクラス振るように組んでも良かったですが、今回はpugのループで組みました。stylus
こちらもループを使い、連番のクラスのpositionを計算して波の形にします。
横軸(left)は単純にループの数値にXpx掛ける。
縦軸(top)はループの数値とsinやcos使って-1~1の数値を計算しそこにオフセットを掛けたり足したりするとOKです。vue
ulについたクラスによって、波の形を変えたいので、ここをバインドにして
buttonに@clickを入れてクラスを変更するようにします。
めちゃ簡単ですね?
- 投稿日:2019-12-19T00:52:53+09:00
Vuex × TypeScriptにvuex-smart-moduleを選んだ理由
テックタッチアドベントカレンダーの18日目を担当する @92thunder です。
昨日は @mxxxxkxxxx による たのしいしごとをしよう でした。テックタッチのエンジニアはkenyuさんとの1on1を通して自分の根本関心を掘り起こされ、成長に繋がる活動を行っているのも大きな特徴になってきています。Vuex × TypeScript
テックタッチではこれまでVuexをTypeScriptで使うために、苦労を重ねてきましたが、vuex-smart-moduleにたどり着いた経緯と各方法の比較を説明します。
経緯
1. 型定義なしの地獄
- 一番最初のプロトタイプをJSで書いてそこに「最初はany使ってゆるく始めればええやろ」と思ってTypeScriptを導入して地獄を作りました。反省しています。
- 愚かだった私はVue/Vuexで型は諦めていましたがTypeScriptの習熟度が上がるにつれて目からハイライトが消えていきました
2. Vuexに定義されている型を活用する
- https://github.com/vuejs/vuex/blob/dev/types/index.d.ts を使う
- ここの型を使って「Vuexはそれっぽくなってきた^^」と思っていたがVueコンポーネントのほうでもTypeScriptの綺麗な世界を作っていった結果、業務委託で手伝ってくれていたメンバーとのミーティングが億劫になる。
- この記事 が近い
3. 自分で型を定義する
- 手伝ってくれていた @all__user さんにほとんど作ってもらい、かなり改善された
- ただstateを一つ追加するために必要なコード量が多すぎた
4. vuex-smart-module
- Twitterでタイムラインをぼーっと眺めてたら vuex-smart-module と出会い、救われる
- @ktsn 先生をフォローしといてよかった
各方法の比較
自分で型を定義する
今はvuex-smart-moduleに置き換えられていて、コードの断片ではありますが、当時のアイデアを供養させてください
// State, Namespace経由でcontextの型を生成 type Ctx = TypedContext<CountState, NameSpace> // objectを元に型付きactionsを返す const actions = createTypedActions({ setCount({ commit }: Ctx, count: number) { commit('setCount', count) } }) // DispatchParametersとしてactionsから型を抜きdispatchを定義する type TypedDispatch<L extends StoreTypes.Namespaces = void> = { ( type: DispatchParameters<L>['setCount'][0], payload: DispatchParameters<L>['setCount'][1], options?: DispatchParameters<L>['setCount'][2] ): DispatchParameters<L>['setCount'][3] } // Componentで↑を使った型を$storeとして扱う export default Index extends Vue { $store: Store onChanged(value: number) { // 型が効いている this.$store.dispatch('setCount', value) } }
去年の記事ではあるが、こちらでも言及されているように、masStateなどが使えるようになるわけではないため、storeへのアクセスが
this.$store経由に限定されてしまう
- Component ↔ store間のアクセスが
this.$store経由だとどのコンポーネントからでも何でもできてしまい複雑性工場になってしまう。ContainerComponentなどから、特定のモジュールにのみアクセスすることでstoreへのアクセスは一気にシンプルになる- このあたりの考え方はこちらの記事を一通り読むとしっくりきた
一連のstate, mutation, actionを1つ追加するだけにしてもコードの記述量が多すぎる
vuex-module-decorators
- vuex-module-decorators は本格的に使っていないため具体的なコードは他の投稿でも多くあるのでここでは割愛する
- おそらく一番スター数の多い vuex-module-decorators を使わなかった理由
- Decoratorを使ったコードはなるべく避けるべきというチームの考え
- vue-property-decoratorは使っているがVue2では避けられないと思う… Vue3にワクワクしている。。
- moduleごとのアクセスはやりやすくなるが、前述の
mapXXXが使えるようになるわけではないのでVuexの自然な使い方とはちょっとずれるvuex-smart-module
Features
* Completely type safe when used with TypeScript without redundancy.
* Provide a smart way to use modules.
* Canonical Vuex-like API interface as possible.
https://github.com/ktsn/vuex-smart-module#features公式のREADMEが全てであり、
Canonical Vuex-like API interface as possible.とある通り使い方もVuexと変わらないため細かい説明は不要だろう
READMEを見て少し使ってみるだけでチーム一致で導入が決定した。
Githubのスター数に惑わされずに決定できたチームメンバーが誇らしい(2019/12/18現在で149starだが、導入当時は10代くらいだったと思う)
- vuex-smart-module
- classを使った自然な書き方でVuex storeを定義できる
- 他のmoduleをinjectする方法で、vuexのdispatchを経由せず書ける部分も気に入っている
- 前述の自作の型と違い、storeの定義を実物と分けて書くようなことをしなくて済む
- mapStateやmapActionsの使い方も自然
- 各モジュールのcontextを取得する方法でstoreにアクセスすれば型が効く
- Nuxt3ではVuexのクラシックモードが廃止されるため、どうなるのかという話があるが幣チームはSSRなどNuxtの機能に強いこだわりが無いためvue-cliに移行も視野
所感
- Vuex × TypeScriptで開発し、苦労を重ねていく中でTypeScriptの習熟度が上がっていった
- vuex-smart-moduleを使うことでTypeScriptの綺麗な世界に近づいた
- OSSの開発者様には頭が上がりません
19日目は @kosy による「Vueで日本全国ダーツの旅的なものを作ってみた(仮)」です。
今年の春からフロントエンドエンジニアになった彼女の成長の集大成が見られるはず。






































