20211201のvue.jsに関する記事は11件です。

Electron+Vue.js+NestJS+TypeScriptでデスクトップアプリのひな型を作る。

作りたいもの 画面はVue.js、ロジック処理はNestJSが担当し、それら2つをElectronでデスクトップアプリとして起動できる事をゴールとします。 プロジェクト構造 project/  ├ frontend/ │ └ Vue.jsで構築  └ backend/ └ NestJSで構築 1. NestJSの立ち上げ 1-1. NestJSのインストール $ npm install @nestjs/cli -g 1-2. NestJSプロジェクトの立ち上げ 次にproject直下でNestJSプロジェクト作成コマンドを実行。 $ nest new backend パッケージマネージャの好みを聞かれるのでyarnを選択。 ? Which package manager would you ❤️ to use? (Use arrow keys) npm > yarn pnpm 1-3. NestJSの起動確認 ファイルが生成されたら起動してみる。 $ cd backend $ yarn run start 立ち上がったらブラウザで「http://localhost:3000/」にアクセスして下記画面が出れば成功です。 2. Electronの追加 2-1. Electronのインストール project/backend直下で下記コマンドを実行。 $ yarn add electron 2-2. NestJSにElectron実行処理を組み込み project/backend/src/main.tsにElectron起動処理を追記します。 main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(3000); } bootstrap(); // ========= 以下を追記 ========= // Electronのモジュール const electron = require('electron') // アプリケーションをコントロールするモジュール const app = electron.app // ウィンドウを作成するモジュール const BrowserWindow = electron.BrowserWindow // メインウィンドウはGCされないようにグローバル宣言 let mainWindow // 全てのウィンドウが閉じたら終了 app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } }) // Electronの初期化完了後に実行 app.on('ready', () => { // メイン画面の表示。ウィンドウの幅、高さを指定できる mainWindow = new BrowserWindow({ width: 1200, height: 720 }) mainWindow.loadURL('http://localhost:3000/') // ウィンドウが閉じられたらアプリも終了 mainWindow.on('closed', () => { mainWindow = null }) }) 2-3. package.jsonの編集 package.json { "name": "backend", "version": "0.0.1", "main": "./dist/src/main.js", ☆追加 ※1 "description": "", "author": "", "private": true, "license": "UNLICENSED", "scripts": { "prebuild": "rimraf dist", "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "tsc && npx electron .", ☆更新 ※2 "start:dev": "nest start --watch", ※1 : TypeScript→JavaScriptへトランスコンパイルされたファイルは/distに出力されているので、Electronの指定も/dist/src配下に出力されるjsを指定します。 ※2 : $ yarn run start一発でトランスコンパイルとElectronの実行をして欲しいので書き換えてます。 2-4. Electronの起動確認 $ cd backend $ yarn run start 下記ウィンドウが表示されれば成功です。(表示されてるHello World!はNestJSのモノです) 3. Vue.jsの立ち上げ 3-1. Vue.jsのインストール $ npm install -g @vue/cli 3-2. Vue.jsプロジェクトの立ち上げ 次にproject直下でVue.jsプロジェクト作成コマンドを実行 $ vue create frontend プリセット選べと言われるのでVue3を選択 ? Please pick a preset: Default ([Vue 2] babel, eslint) > Default (Vue 3) ([Vue 3] babel, eslint) Manually select features パッケージマネージャの好みを聞かれるのでyarnを選択。 ? Pick the package manager to use when installing dependencies: (Use arrow keys) > Use Yarn Use NPM 3-3. Vue.jsの起動確認 ファイルが生成されたら起動してみる。 $ cd frontend $ yarn serve 立ち上がったらブラウザで「http://localhost:8080/」にアクセスして下記画面が出れば成功です。 4. Vue.jsの組み込み 4-1. NestJSにhtml格納先ディレクトリを追加 project/  └ backend/ └ public ☆作成 4-2. Vue.jsのbuild出力先をNestJSに切り替え project/frontend/vue.config.jsを作成し、下記内容を追加します。 vue.config.js module.exports = { outputDir:'../backend/public', } 下記コマンドを実行してproject/backend/publicにindex.htmlなどが出力されれば成功です。 $ cd frontend $ yarn build 4-3. NestJSにhtml読み込み定義を追加 project/backend/src/main.tsに以下処理を追記します。 main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import {NestExpressApplication} from '@nestjs/platform-express'; // 追加 import {join} from 'path'; // 追加 async function bootstrap() { const app = await NestFactory.create<NestExpressApplication>(AppModule); // 編集 app.useStaticAssets(join(__dirname, '../..', 'public')) // 追加 ※1 await app.listen(3000); } bootstrap(); ※1 トランスコンパイル後の/dist/src/main.jsから見たpublicへの相対パスを設定します。 4-4. Electron + NestJS + Vue.js組み合わせ起動確認 $ cd backend $ yarn run start 下記ウィンドウが表示されれば成功です。 ウィンドウはElectron・表示内容はVue.js・表示内容を返すNestJSとなっています。 5. デスクトップアプリとしてビルド 5-1. electron-builder のインストール $ cd backend $ yarn add electron-builder --dev 5-2. package.jsonの編集 package.jsonにbuildの定義を追加します。 またdevDependenciesにelectronの定義を移動します。 package.json { "name": "backend", "version": "0.0.1", "main": "dist/src/main.js", "description": "", "author": "", "private": true, "license": "UNLICENSED", "scripts": { ~~ "build:exe": "electron-builder" // 追加 ~~ "build": { // 追加 "appId": "com.example.myapp", "productName": "MyApp", // ※1 "win": { "target": { "target": "zip", "arch": [ "x64" ] } }, "files": [ // ※2 "dist/**/*", "public/**/*" ] }, "devDependencies": { "electron": "^16.0.2", // dependenciesから移動 "electron-builder": "^22.14.5", ※1 ビルド後のアプリ名です。(~.exeになります) ※2 NestJSのソースはdist/、Vue.jsのデータはpublic/配下にあるので、この2つを取り込む設定をします。 5-3. ビルド実行 $ cd backend $ yarn run build:exe ビルドが完了するとproject/backend/dist/win-unpackedに実行ファイルが出力されます。 起動してみるとデスクトップアプリとして下記の様に実行されます。 参考文献
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vueがメチャメチャ書きやすくなる言語を開発した話

皆様はじめまして、s19514ttと申します。 Vueがメチャメチャ書きやすくなる言語を開発したので、開発に至った経緯やどのようなものなのかのご紹介をさせていただきます。 開発のきっかけ みなさんVueはお好きでしょうか?私は大好きで、よくVueを使って趣味でWebアプリケーションを作っていました。しかし、最初にVueを勉強してからちょっとしたモヤモヤがありました。 言うまでもなくVueはJavaScriptで制作されており記述する際はJavaScript又はTypeScriptを用いて記述します。 しかし、実際はVueと素のJavaScriptやJQueryのコードには大きな差があり、全く別の言語のようとまでは言えないもののJavaScriptを知っている人でもVueを書くために様々な学習が必要になります。例えば、初期値0のcount変数を1つずつincrementで増やすと言うコードを書きたい場合JavaScriptとVueでは以下のように大きく異なったコードになります。 JavaScriptの例 const count = 0; function increment(){ count++; } Vueの例 new Vue({ el: "#app", data() { return { count: 0 }; }, methods: { increment() { this.count++; }, } }); 全く同じ処理を書いているのにVueの場合かなり長いコードになっているのがわかると思います。 さらに、変数宣言と変数1の場所が固定されていることや、変数をconstなどを使わずオブジェクトのプロパティとして定義するのも、最初Vueを学習した自分にとって衝撃でした。 また、関数も同様にVueで用いる場合はオブジェクトのプロパティとして記述しないといけません。通常のJavaScriptはもちろん他の言語でもこのような仕様の言語はないためVueのコードの大きな癖となってしまい、多くの人にとって書いたり読んだりしやすいコードではないでしょう。 もちろん上のJavaScript例を実際に書いた場合はincrement関数を実行してもcountの値は更新されないためHTMLに反映させたい場合は別途DOM操作を行う必要があります。 ただ私は「今までのJSみたいなコードで自動的にDOM操作できないかな〜」って思っていました。 さらに、TypeScriptを利用するようになってからはそのデメリットをさらに強く感じるようになりました。 Vueで変数を定義する際にTypeScriptだと以下のように書くと思います。 var Profile = Vue.extend({ template: '<p>{{firstName}} {{lastName}} aka {{alias}}</p>', data: function () { return { firstName: 'Walter', lastName: 'White', alias: 'Heisenberg' } } }) Vueの公式ドキュメントより引用 一見上記は問題のないようなコードに見えると思いますが、firstName,lastName,alias3つの変数は変数が型推論によって決められてしまっています。 もちろん型を指定することもできますが、TypeScriptのオブジェクトで型を指定する場合asを用いる必要があり、この書き方に違和感を覚える方も多いと思います。 data: function () { return { firstName: 'Walter' as string, lastName: 'White' as string, alias: 'Heisenberg' as string } 実装してみた 以前から思っていたこの問題に対処するために、ライブラリを実装してみました! その名もVucript(Vue+Script)です! セットアップ Vue CliでTypeScriptを使用したプロジェクトを作成する (Vue 2の場合のみ)このステップに従いComposition APIを使用できるようにする コマンドラインにて下記のコマンドを入力しVucriptをインストールする vue add vucript VueのSFCの言語にVucriptと記述する <script lang="vucript"> Vucriptを使用する際は必ずIDE等でPrettierによる自動フォーマットをオンにしてください。 使用例 先程の例に挙げたカウンターアプリケーションを作ってみましょう! まず最初にセットアップに従いプロジェクトを作成します。 次にApp.vue以下のようにコードを記述します。 <template> <div>現在の値:{{ counter }}</div> <button @click="increment()">1増やす</button> </template> <script lang="vucript"> import { reactive } from "vucript"; let counter: reactive<number> = 0; function increment() { counter++; } </script> この状態でnpm run serveを実行してみましょう あら不思議(?)、これだけのコードでカウンターアプリが実装できてしまいました!コードをもう少し細かくみてみましょう 変数定義 let counter: reactive<number> = 0; HTML側から参照可能な値はreactive型として定義することができます! みてわかる通り普通のTypeScriptと同様の変数宣言ができます。 当然reactive型の変数はHTMLから参照でき、値を変更するたびに変更が反映されます! 関数定義 function increment() { counter++; } 関数定義も通常のTypeScriptのように自然な形で書くことができます。reacrive型という特殊な型でcounter変数は定義していますが、counterを1増やすときは特殊な書き方はせずcounter++のように普通に操作できます。 当然アロー関数などを用いて関数を定義することも可能です! これならJavaScriptの初学者やJQueryを中心に使ってきたエンジニアもほとんど学習時間なしでVueのコードを書くことができます。 ComputedなどのVueならではの機能も使える! ComputedはVueで利用できる算出プロパティですが、Vucriptでも利用することができます。 const counter:reactive<number> = 0; const twiceTheCounter:computed<number> = (()=>counter*2);//. counterの値を2倍した値を返している 他にもWatch関数なども利用できるます!詳しい使い方はドキュメントに記述されています。 課題 現状Vucriptには多数のバグがあることやPrettierの利用が必須であることが問題と言えます。 また、IDEに関してはVeturというVSCodeプラグインをフォークしてVucriptへ対応するようにしましたが、残念ながらプルリクを蹴られてしまったので、今現在VSCodeでVucriptを使うときは私がフォークしたバージョンをダウンロードてください。 (私がフォークしたバージョンを使う場合はVSCode上でVeturのアップデートをしないようにお願いします) 最後に 拙い文章を最後まで読んでいただきありがとうございました。 もしよければこの記事へのLGTMとVucriptのGitHubへスターをお願いします!(GitHubへのリンクは下にあります) 質問があれば私のツイッターに気軽にリプライをお願いします! 関連ページ Vucript - Github - スターお願いします Vucriptドキュメント vue-cli-plugin-vucript - npm Vucript - npm Vueのdataは実際変数ではないのですが、通常のJavaScriptの変数と役割がほとんど同じであるため本記事中では変数と表現させていただきます ↩
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Vue.js】ディレクティブ v-bind

ディレクティブ ディレクティブとは、DOM要素に対して何かを実行することをライブラリに伝達する、マークアップ中の特別なトークンです。 v-bind htmlタグに属性を付ける場合などに使用する。 case1 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.js"></script> </head> <body> <div id="app"> {{ message }} <!-- href属性にdataで定義したgoogleプロパティを追加することができる。 --> <a v-bind:href="google">googleへのリンク</a> <!-- 省略記法 --> <a :href="google">googleへのリンク</a> </div> <script> let app = new Vue({ el: '#app', data(){ return{ message:'Hello Vue!', google: 'https://google.com' } } }) </script> </body> </html> case2 オブジェクト形式で書くことも可能。 DOMの属性に対してプロパティ名が同じの場合は省略して書くことができる。 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.js"></script> </head> <body> <div id="app"> {{ message }} <a v-bind:href="google">googleへのリンク</a> <br> <a :href="book.url"></a>{{book.title}}</a> <br> <input v-bind="formInput"> DOMの属性に対してプロパティ名が同じの場合は省略して書/くことができる。 <br> <input v-bind="{name:formInput.name,placeholder:formInput.placeholder }"> </div> <script> let app = new Vue({ el: '#app', data(){ return{ message:'Hello Vue!', google: 'https://google.com', book:{ title: '宇宙はなぜこんなに上手くできているのか', url:'https://www.amazon.co.jp/%E5%AE%87%E5%AE%99%E3%81%AF%E3%81%AA%E3%81%9C%E3%81%93%E3%82%93%E3%81%AA%E3%81%AB%E3%81%86%E3%81%BE%E3%81%8F%E3%81%A7%E3%81%8D%E3%81%A6%E3%81%84%E3%82%8B%E3%81%AE%E3%81%8B-%E7%9F%A5%E3%81%AE%E3%83%88%E3%83%AC%E3%83%83%E3%82%AD%E3%83%B3%E3%82%B0%E5%8F%A2%E6%9B%B8-%E6%9D%91%E5%B1%B1%E6%96%89-ebook/dp/B00MTUI0LA' }, formInput:{ name:'your_name', placeholder:'お名前を入力してください。' } } } }) </script> </body> </html> case3 syleやclassの設定ができる。 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.js"></script> <style> .active { border:10px, solid red; } </style> </head> <body> <div id="app"> //cssプロパティなどでスネークケースプロパティがあるが使用できない。 //変わってキャメルケースで記述する必要がある。`font-size` > fontSize <h1 :style="{fontSize:fontSize, color:color}">いろはす</h1> //isAcriveに真偽値を設定することで表示非表示ができる。 <div :class="{active: isActive}">classテスト</div> </div> <script> const app = new Vue({ el: '#app', data(){ return { fontSize:"50px", color:"green", isActive: true } } }) </script> </body> </html>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

フォームコンポーネント作りながら理解するCompositionApi

はじめに おはようございます。こんにちは。こんばんは。 Watatakuです。 今回はフォームコンポーネントを作りながらVue3で登場したcompositionApiについて解説していく記事です。 またcompositionApiを使うためにVue3にアップグレード(しなくても良かった)したのでVue2とVue3の違いについてもお話しできればと思います。 今回作ったやつ。⬇︎ コード 環境(Vueのバージョン) optionsApi: ver.2.6.11 compositionApi: ver.3.0.0 書かないこと optionApiの書き方、解説。 Vue Composition APIとは 以下の2点のために策定された関数ベースのAPIです。 Vue Composition APIのメリット 1.コードの可読性・再利用性の改善 2.型インターフェースの改善 今までのVue2でのOptions APIでは1つのコンポーネントが複数の役割を持った際にコードが肥大化し、可読性が著しく低下するという問題がありました。 そこでComposition APIでは関心事によってコードを分割し、分割したコードを簡単にコンポーネントに注入できるようになっています。 詳しくはこちら テキストボックス optionsApi App.vue <template> <div class="contents"> <TextInput v-model="text" type="text" name="text" placeholder="テキストボックス" :value="text" /> </div> </template> <script> import TextInput from "./components/TextInput.vue"; export default { components: { TextInput, }, data() { return { text: "", }; }, }; </script> TextInput.vue <template> <input :type="this.type" :placeholder="this.placeholder" :name="this.name" :value="this.value" @input="updateValue" /> </template> <script> export default { props: { type: { type: String }, placeholder: { type: String }, name: { type: String }, value: { type: String }, }, methods: { updateValue(e) { this.$emit("input", e.target.value); }, }, }; </script> compositionApi App.vue <template> <TextInput v-model:modalValue="form.text" type="text" name="text" placeholder="テキストボックス" :value="form.text" /> </template> <script> import { reactive } from "vue"; import TextInput from "./components/TextInput.vue"; export default { name: "App", components: { TextInput, }, setup() { const form = reactive({ text: "", }); return { form }; }, }; </script> TextInput <template> <input :type="type" :placeholder="placeholder" :name="name" :value="value" @input="updateValue" /> </template> <script> export default { props: { type: { type: String, required: true }, placeholder: { type: String, required: true }, name: { type: String, required: true }, value: { type: String, required: true }, }, emits: ["update:modalValue"], setup(_, context) { const updateValue = (e) => { context.emit("update:modalValue", e.target.value); }; return { updateValue }; }, }; </script> optionApiとcompositionApiを見比べて大きく違うのはsetup(){}の有無ではないでしょうか? このsetup(){}がCompositionApiを扱うためのキモになります。 このsetup(){}の中に、optionApiで言う「data, mounted, methods, ・・・・」を描くイメージです。 data() まずはじめにdata()をみていきます。 結論から申し上げますと, "vue"からref or reactiveで宣言すればいいです。 そして、その変数をテンプレートで扱うためにreturnします。 // --------------- optionsApi ---------------------- data() { return { text: "", }; }, // アクセス this.text // -------------- compositionApi -------------- // reactiveで書く場合 setup() { const form = reactive({ text: "", }); return { form }; }, // アクセス form.text // refで書く場合 setup() { const text = ref(""); return { text }; }, // アクセス text.value refとreactiveの違いについては下記記事がご参考になると思います。 参照 methods optionsApiでは、メソッドはmethodsに書かなくてはなりませんでしたが、 compositionApiではこれもsetup(){}に記述します。 書き方は、setup(){}で普通にjavascript(typescriptを使うのであれば、typescript)でメソッド宣言するだけです。 あとはdata()同様、returnするだけ。 //optionApi methods: { updateValue(e) { this.$emit("input", e.target.value); }, }, //compositionApi setup(_, context) { const updateValue = (e) => { context.emit("update:modalValue", e.target.value); }; return { updateValue }; }, propsとemit props propsの渡し方、受け取りかたは大きく変わってないです。 ただ、受け取ったpropsを用いてデータを加工したりする時(setup関数に渡さなければいけない時)に工夫が必要です。 props: { title: { type: String, required: true }, count: { type: Number, default: 0 } }, setup (props) { const doubleCount = computed(() => props.count * 2) return { doubleCount } } setup(){}の第一引数がpropsになります。ちなみに後述しますが第ニ引数がコンテキスト。 コンテキストを用いてemitします。 emit まずはじめに、Vue2とVue3で仕様が若干変わっていたので、作者は苦戦しました。ちなみにv-modelも若干仕様が変わっています。 詳しくはこちら。 v-model emit emits: ["update:modalValue"], setup(_, context) { const updateValue = (e) => { context.emit("update:modalValue", e.target.value); }; return { updateValue }; } Vue2との違いは事前にemitのイベントをemitsプロパティに指定します。 その後、propsの時にも少し触れたのですが、setup(){}の第二引数でコンテキストを指定し、 コンテキストのemitメソッドを使います。 ちなみにpropsが必要なく、emitだけを使いたい場合は上記のように(_, context)のように書きます。 他のcompositionApiの書き方 以上が、このアプリでのcompositionApiの説明でした。 おい、Watataku!!残りのライフサイクルとかwatchはどないすんねん!! と言うことで、ここからはこのアプリとは関係ないですがライフサイクルとかwatch*を説明していきましょう。 結論、importしろ。これだけです。 ライフサイクル // optionsApi export default { created() { console.log("created"); }, mounted() { console.log("mounted"); }, }; // compositionApi import { onMounted } from "vue"; export default { setup() { console.log("created"); onMounted(() => { console.log("mounted"); }); }, }; それぞれのライフサイクルに対応したonXXX関数を使用する beforeCreate・createdがsetupにまとめられている computed //optionsApi export default { data() { return { count: 1, }; }, computed: { doubleCount() { return this.count * 2; }, }, }; //compositionApi import { ref, computed } from "vue"; export default { setup() { const countRef = ref(1); // computed関数を使用して計算プロパティを定義します const doubleCount = computed(() => { return countRef.value * 2; }); return { countRef, doubleCount, }; }, }; watch //optionsApi export default { data() { return { count: 1, }; }, watch: { count(count, prevCount) { console.log(count); console.log(prevCount); }, }, }; //compositionApi import { ref, watch } from "vue"; export default { setup() { const countRef = ref(1); // watch関数でリアクティブな変数を監視します watch(countRef, (count, prevCount) => { console.log(count); console.log(prevCount); }); return { countRef, }; }, }; watchの第1引数には監視対象、第2引数にはcallback関数を渡します。 callback関数の第1引数には変更後の値、第2引数には変更前の値が渡されます。 画像アップローダー ここからはフォームコンポーネントの続きをやっていきます。 パスワードとテキストエリアに関してはテキストボックスと同じような作りなので省略します。 optionsApi App.vue <template> <div class="contents"> <div class="imgContent"> <ImagePreview :imageUrl="imageUrl" /> <div class="module--spacing--largeSmall"></div> <UploadFile @fileList="setFileList" /> </div> </div> </template> <script> import ImagePreview from "./components/ImagePreview.vue"; import UploadFile from "./components/UploadFile.vue"; export default { components: { ImagePreview, UploadFile, }, data() { return { imageUrl: "", fileList: null, }; }, methods: { setFileList(fileList) { this.fileList = fileList; const imageUrl = URL.createObjectURL(fileList[0]); this.imageUrl = imageUrl; }, } }; </script> <style> @media screen and (min-width: 1026px) { .imgContent { width: 90%; max-width: 700px; height: 35vh; margin: auto; margin-bottom: 10px; background-color: #ccc; padding-top: 5%; } } @media screen and (min-width: 482px) and (max-width: 1025px) { .imgContent { width: 90%; max-width: 700px; height: 20vh; margin: auto; margin-bottom: 10px; background-color: #ccc; padding-top: 5%; } } </style> UploadFile.vue <template> <label for="corporation_file" class="btn btn-success"> 画像を設定する <input type="file" class="file_input" style="display:none;" id="corporation_file" mulitple="multiple" @change="onDrop" /> </label> </template> <script> export default { methods: { onDrop(e) { const imageFile = e.target.files; if(imageFile) { this.$emit("fileList", imageFile); } }, }, }; </script> <style scoped> label { background-color: #fff; padding: 1%; width: 40%; margin: 0 auto; box-shadow: 1px 1px 8px 0px #000; display: block; } </style> ImagePreview.vue <template> <div class="imagePreview"> <img :src="this.imageUrl" width="50" height="50" alt /> </div> </template> <script> export default { props: ["imageUrl"], }; </script> <style scoped> @media screen and (min-width: 1026px) { .imagePreview { height: 200px; width: 200px; background: rgb(240, 240, 240); overflow: hidden; border-radius: 50%; background-position: center center; background-size: cover; margin-left: auto; margin-right: auto; margin-bottom: 20px; position: relative; } .imagePreview img { height: 200px; width: 200px; } } @media screen and (min-width: 482px) and (max-width: 1025px) { .imagePreview { height: 100px; width: 100px; background: rgb(240, 240, 240); overflow: hidden; border-radius: 50%; background-position: center center; background-size: cover; margin-left: auto; margin-right: auto; margin-bottom: 20px; position: relative; } .imagePreview img { height: 100px; width: 100px; } } @media screen and (max-width: 481px) { .imagePreview { height: 50px; width: 50px; background: rgb(240, 240, 240); overflow: hidden; border-radius: 50%; background-position: center center; background-size: cover; margin-left: auto; margin-right: auto; margin-bottom: 20px; position: relative; } .imagePreview img { height: 50px; width: 50px; } } </style> compositionApi App.vue <template> <div class="imgContent"> <ImagePreview :imageUrl="form.imageUrl" /> <div class="module--spacing--largeSmall"></div> <UploadFile @fileList="setFileList" /> </div> </template> <script> import { reactive } from "vue"; import ImagePreview from "./components/ImagePreview.vue"; import UploadFile from "./components/UploadFile.vue"; export default { name: "App", ImagePreview, UploadFile, }, setup() { const form = reactive({ imageUrl: "", fileList: null, }); const setFileList = (fileList) => { form.fileList = fileList; const imgUrl = URL.createObjectURL(fileList[0]); form.imageUrl = imgUrl; }; return { form, setFileList, }; }, }; </script> <style> @media screen and (min-width: 1026px) { .imgContent { width: 90%; max-width: 700px; height: 35vh; margin: auto; margin-bottom: 10px; background-color: #ccc; padding-top: 5%; } } @media screen and (min-width: 482px) and (max-width: 1025px) { .imgContent { width: 90%; max-width: 700px; height: 20vh; margin: auto; margin-bottom: 10px; background-color: #ccc; padding-top: 5%; } } </style> UploadFile.vue <template> <label for="corporation_file" class="btn btn-success"> 画像を設定する <input type="file" class="file_input" style="display: none" id="corporation_file" mulitple="multiple" @change="onDrop" /> </label> </template> <script> export default { emits: ["fileList"], setup(_, context) { const onDrop = (e) => { const imageFile = e.target.files; if (imageFile) { context.emit("fileList", imageFile); } }; return { onDrop }; }, }; </script> <style scoped> label { background-color: #fff; padding: 1%; width: 40%; margin: 0 auto; box-shadow: 1px 1px 8px 0px #000; display: block; } </style> ImagePreview.vue <template> <div class="imagePreview"> <img :src="imageUrl" alt /> </div> </template> <script> export default { props: { imageUrl: { type: String, required: true, }, }, }; </script> <style scoped> @media screen and (min-width: 1026px) { .imagePreview { height: 200px; width: 200px; background: rgb(240, 240, 240); overflow: hidden; border-radius: 50%; background-position: center center; background-size: cover; margin-left: auto; margin-right: auto; margin-bottom: 20px; position: relative; } .imagePreview img { height: 200px; width: 200px; } } @media screen and (min-width: 482px) and (max-width: 1025px) { .imagePreview { height: 100px; width: 100px; background: rgb(240, 240, 240); overflow: hidden; border-radius: 50%; background-position: center center; background-size: cover; margin-left: auto; margin-right: auto; margin-bottom: 20px; position: relative; } .imagePreview img { height: 100px; width: 100px; } } @media screen and (max-width: 481px) { .imagePreview { height: 50px; width: 50px; background: rgb(240, 240, 240); overflow: hidden; border-radius: 50%; background-position: center center; background-size: cover; margin-left: auto; margin-right: auto; margin-bottom: 20px; position: relative; } .imagePreview img { height: 50px; width: 50px; } } </style> セレクトボックス optionsApi App.vue <template> <div class="contents"> <SelextBox v-model="select" :options="optionsSelect" /> <span v-if="this.select == ''">選択オプション:選択してください。</span> <span v-else>選択オプション: {{ select }}</span> </div> </template> <script> import SelextBox from "./components/SelectBox.vue"; export default { components: { SelextBox, }, data() { return { select: 0, selectValue: "", optionsSelect: [ { label: "Vue.js", value: "Vue.js" }, { label: "React", value: "React" }, { label: "Angular", value: "Angular" }, ], }; }, }; </script> SelectBox.vue <template> <select name="select-box" @input="updateValue"> <option value="0">選択してください</option> <option v-for="(option, index) in options" :key="index" :value="option.value" >{{ option.label }}</option > </select> </template> <script> export default { props: { options: { type: Array, required: true }, }, methods: { updateValue(e) { this.$emit("input", e.target.value); }, }, }; </script> compositionApi App.vue <template> <SelectBox v-model:select="form.select" name="selectbox" :options="form.optionsSelect" /> <div class="module--spacing--verySmall"></div> <span v-if="form.select == ''">選択オプション:選択してください。</span> <span v-else>選択オプション: {{ form.select }}</span> </template> <script> import { reactive } from "vue"; import SelectBox from "./components/SelectBox.vue"; export default { name: "App", components: { SelectBox, }, setup() { const form = reactive({ select: 0, selectValue: "", optionsSelect: [ { label: "Vue.js", value: "Vue.js" }, { label: "React", value: "React" }, { label: "Angular", value: "Angular" }, ], }); return { form, }; }, }; </script> SelectBox.vue <template> <select :name="name" @input="updateValue"> <option value="0">選択してください</option> <option v-for="(option, index) in options" :key="index" :value="option.value" > {{ option.label }} </option> </select> </template> <script> export default { props: { name: { type: String, required: true }, options: { type: Array, required: true }, }, emits: ["update:select"], setup(_, context) { const updateValue = (e) => { context.emit("update:select", e.target.value); }; return { updateValue }; }, }; </script> ラジオボタン optionsApi App.vue <template> <div class="contents"> <RadioButton v-model="checkName" :options="optionsRadio" /> <span>選択オプション: {{ checkName }}</span> </div> </template> <script> import RadioButton from "./components/RadioButton.vue"; export default { components: { RadioButton, }, data() { return { checkName: "選択してね", optionsRadio: [ { label: "hoge", value: "hoge" }, { label: "bow", value: "bow" }, { label: "fuga", value: "fuga" }, ], }; }, }; </script> RadioButton <template> <div> <label v-for="(option, index) in options" :key="index"> <!-- ラジオボタンにはname属性必須 --> <input type="radio" :value="option.value" @change="updateValue" name="radio-button" />{{ option.label }}</label > </div> </template> <script> export default { props: { options: { type: Array, required: true }, }, methods: { updateValue(e) { this.$emit("input", e.target.value); }, }, }; </script> compositionApi App.vue <template> <RadioButton v-model:modalValue="form.checkName" :options="form.optionsRadio" /> <span>選択オプション: {{ form.checkName }}</span> </template> <script> import { reactive } from "vue"; import RadioButton from "./components/RadioButton.vue"; export default { name: "App", components: { RadioButton, }, setup() { const form = reactive({ checkName: "選択してね", optionsRadio: [ { label: "hoge", value: "hoge" }, { label: "bow", value: "bow" }, { label: "fuga", value: "fuga" }, ], }); return { form, }; }, }; </script> RadioButton.vue <template> <div> <label v-for="(option, index) in options" :key="index"> <!-- ラジオボタンにはname属性必須 --> <input type="radio" :value="option.value" @change="updateValue" name="radio-button" />{{ option.label }}</label > </div> </template> <script> export default { props: { options: { type: Array, required: true }, }, emits: ["update:modalValue"], setup(_, context) { const updateValue = (e) => { context.emit("update:modalValue", e.target.value); }; return { updateValue }; }, }; </script> ボタン チェックボックスにチェックを入れないとボタンがクリックできなくて、 そのボタンをクリックしたらモーダルが出現するものを実装します。(説明下手でごめんなさい。) optionsApi App.vue <template> <div class="contents"> <CheckBox v-model="checked" :checked="checked" /> <label>同意</label><br /> <Button :disabled="!checked" msg="モーダルが出ます" @push="click" /> <Modal title="モーダルタイトル" detail="モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。" v-if="open" @close="open = false" @modal-click="modalClick" /> </div> </template> <script> import CheckBox from "./components/CheckBox.vue"; import Button from "./components/Button.vue"; import Modal from "./components/Modal.vue"; export default { components: { CheckBox, Button, Modal, }, data() { return { checked: false, open: false, }; }, methods: { click() { this.open = true; }, modalClick() { console.log("テキストボックスの入力内容:", this.text); console.log("パスワードの入力内容:", this.pass); console.log("テキストエリアの入力内容:", this.textarea); console.log("画像の名前:", this.fileList[0].name); alert("コンソールを見ろ!!"); this.open = false; this.checked = false; // this.uploadImage(); }, }; </script> CheckBox.vue <template> <input type="checkbox" @change="updateValue" :checked="this.checked" /> </template> <script> export default { props: ["checked"], methods: { updateValue(e) { this.$emit("input", e.target.checked); }, }, }; </script> Button.vue <template> <button class="button" @click="push"> {{ this.msg }} </button> </template> <script> export default { props: ["msg"], methods: { push() { this.$emit("push"); }, }, }; </script> Modal.vue <template> <transition name="modal"> <div class="overlay" @click="$emit('close')"> <div class="panel" @click.stop> <h2>{{ this.title }}</h2> <div class="module--spacing--small"></div> <div class="modal-contents"> <p>{{ this.detail }}</p> </div> <Button :disabled="false" msg="ボタン" @push="click" /> </div> </div> </transition> </template> <script> import Button from "./Button.vue"; export default { props: ["title", "detail"], components: { Button, }, methods: { click() { this.$emit("modal-click"); }, }, }; </script> <style scoped> .overlay { background: rgba(0, 0, 0, 0.8); position: fixed; width: 100%; height: 100%; left: 0; top: 0; right: 0; bottom: 0; z-index: 900; transition: all 0.5s ease; } .panel { width: 40%; text-align: center; background: #fff; padding: 40px; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } .modal-contents { text-align: left; } .modal-enter, .modal-leave-active { opacity: 0; } .modal-enter .panel, .modal-leave-active .panel { top: -200px; } </style> compositionApi App.vue <template> <CheckBox v-model:change="form.checked" :checked="form.checked" /> <label>同意</label><br /> <Button :disabled="!form.checked" msg="モーダルが出ます" @push="handleOpen" /> <Modal title="モーダルタイトル" detail="モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。" v-if="form.open" @close="form.open = false" @modal-click="modalClick" /> </template> <script> import { reactive } from "vue"; import CheckBox from "./components/CheckBox.vue"; import Button from "./components/Button.vue"; import Modal from "./components/Modal.vue"; export default { name: "App", components: { CheckBox, Button, Modal, }, setup() { const form = reactive({ checked: false, open: false, }); const handleOpen = () => { form.open = true; }; const modalClick = () => { aleat("モーダルの中のボタンをクリックしました") }; return { form, handleOpen, modalClick }; }, }; </script> CheckBox.vue <template> <input type="checkbox" @change="updateValue" :checked="checked" /> </template> <script> export default { model: { prop: "checked", event: "change", }, props: { checked: { type: Boolean, required: true, }, }, emits: ["update:change"], setup(_, context) { const updateValue = (e) => { context.emit("update:change", e.target.checked); }; return { updateValue }; }, }; </script> Button.vue <template> <button class="button" @click="push"> {{ msg }} </button> </template> <script> export default { props: { msg: { type: String, required: true, }, }, emits: ["push"], setup(_, context) { const push = () => { context.emit("push"); }; return { push }; }, }; </script> Modal.vue <template> <transition name="modal"> <div class="overlay" @click="handleClose"> <div class="panel" @click.stop> <h2>{{ title }}</h2> <div class="module--spacing--small"></div> <div class="modal-contents"> <p>{{ detail }}</p> </div> <Button :disabled="false" msg="ボタン" @push="click" /> </div> </div> </transition> </template> <script> import Button from "./Button.vue"; export default { props: { title: { type: String, required: true, }, detail: { type: String, required: true, }, }, components: { Button, }, emits: ["close", "modal-click"], setup(_, context) { const handleClose = () => { context.emit("close"); }; const click = () => { context.emit("modal-click"); }; return { handleClose, click }; }, }; </script> <style scoped> .overlay { background: rgba(0, 0, 0, 0.8); position: fixed; width: 100%; height: 100%; left: 0; top: 0; right: 0; bottom: 0; z-index: 900; transition: all 0.5s ease; } .panel { width: 40%; text-align: center; background: #fff; padding: 40px; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } .modal-contents { text-align: left; } .modal-enter, .modal-leave-active { opacity: 0; } .modal-enter .panel, .modal-leave-active .panel { top: -200px; } </style> まとめ 以上。 フォームコンポーネントを作りながらcompositionApiについて書かせていただきました。 ご参考になれば幸いです。 もし間違いなどがあれば、ご教授お願いします。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

フォームコンポーネントを作りながら理解するCompositionApi

はじめに おはようございます。こんにちは。こんばんは。 Watatakuです。 今回はフォームコンポーネントを作りながらVue3で登場したcompositionApiについて解説していく記事です。 またcompositionApiを使うためにVue3にアップグレード(しなくても良かった)したのでVue2とVue3の違いについてもお話しできればと思います。 今回作ったやつ。⬇︎ コード 環境(Vueのバージョン) optionsApi: ver.2.6.11 compositionApi: ver.3.0.0 書かないこと optionApiの書き方、解説。 Vue Composition APIとは 以下の2点のために策定された関数ベースのAPIです。 Vue Composition APIのメリット 1.コードの可読性・再利用性の改善 2.型インターフェースの改善 今までのVue2でのOptions APIでは1つのコンポーネントが複数の役割を持った際にコードが肥大化し、可読性が著しく低下するという問題がありました。 そこでComposition APIでは関心事によってコードを分割し、分割したコードを簡単にコンポーネントに注入できるようになっています。 詳しくはこちら テキストボックス optionsApi App.vue <template> <div class="contents"> <TextInput v-model="text" type="text" name="text" placeholder="テキストボックス" :value="text" /> </div> </template> <script> import TextInput from "./components/TextInput.vue"; export default { components: { TextInput, }, data() { return { text: "", }; }, }; </script> TextInput.vue <template> <input :type="this.type" :placeholder="this.placeholder" :name="this.name" :value="this.value" @input="updateValue" /> </template> <script> export default { props: { type: { type: String }, placeholder: { type: String }, name: { type: String }, value: { type: String }, }, methods: { updateValue(e) { this.$emit("input", e.target.value); }, }, }; </script> compositionApi App.vue <template> <TextInput v-model:modalValue="form.text" type="text" name="text" placeholder="テキストボックス" :value="form.text" /> </template> <script> import { reactive } from "vue"; import TextInput from "./components/TextInput.vue"; export default { name: "App", components: { TextInput, }, setup() { const form = reactive({ text: "", }); return { form }; }, }; </script> TextInput <template> <input :type="type" :placeholder="placeholder" :name="name" :value="value" @input="updateValue" /> </template> <script> export default { props: { type: { type: String, required: true }, placeholder: { type: String, required: true }, name: { type: String, required: true }, value: { type: String, required: true }, }, emits: ["update:modalValue"], setup(_, context) { const updateValue = (e) => { context.emit("update:modalValue", e.target.value); }; return { updateValue }; }, }; </script> optionApiとcompositionApiを見比べて大きく違うのはsetup(){}の有無ではないでしょうか? このsetup(){}がCompositionApiを扱うためのキモになります。 このsetup(){}の中に、optionApiで言う「data, mounted, methods, ・・・・」を描くイメージです。 data() まずはじめにdata()をみていきます。 結論から申し上げますと, "vue"からref or reactiveで宣言すればいいです。 そして、その変数をテンプレートで扱うためにreturnします。 // --------------- optionsApi ---------------------- data() { return { text: "", }; }, // アクセス this.text // -------------- compositionApi -------------- // reactiveで書く場合 setup() { const form = reactive({ text: "", }); return { form }; }, // アクセス form.text // refで書く場合 setup() { const text = ref(""); return { text }; }, // アクセス text.value refとreactiveの違いについては下記記事がご参考になると思います。 参照 methods optionsApiでは、メソッドはmethodsに書かなくてはなりませんでしたが、 compositionApiではこれもsetup(){}に記述します。 書き方は、setup(){}で普通にjavascript(typescriptを使うのであれば、typescript)でメソッド宣言するだけです。 あとはdata()同様、returnするだけ。 //optionApi methods: { updateValue(e) { this.$emit("input", e.target.value); }, }, //compositionApi setup(_, context) { const updateValue = (e) => { context.emit("update:modalValue", e.target.value); }; return { updateValue }; }, propsとemit props propsの渡し方、受け取りかたは大きく変わってないです。 ただ、受け取ったpropsを用いてデータを加工したりする時(setup関数に渡さなければいけない時)に工夫が必要です。 props: { title: { type: String, required: true }, count: { type: Number, default: 0 } }, setup (props) { const doubleCount = computed(() => props.count * 2) return { doubleCount } } setup(){}の第一引数がpropsになります。ちなみに後述しますが第ニ引数がコンテキスト。 コンテキストを用いてemitします。 emit まずはじめに、Vue2とVue3で仕様が若干変わっていたので、作者は苦戦しました。ちなみにv-modelも若干仕様が変わっています。 詳しくはこちら。 v-model emit emits: ["update:modalValue"], setup(_, context) { const updateValue = (e) => { context.emit("update:modalValue", e.target.value); }; return { updateValue }; } Vue2との違いは事前にemitのイベントをemitsプロパティに指定します。 その後、propsの時にも少し触れたのですが、setup(){}の第二引数でコンテキストを指定し、 コンテキストのemitメソッドを使います。 ちなみにpropsが必要なく、emitだけを使いたい場合は上記のように(_, context)のように書きます。 他のcompositionApiの書き方 以上が、このアプリでのcompositionApiの説明でした。 おい、Watataku!!残りのライフサイクルとかwatchはどないすんねん!! と言うことで、ここからはこのアプリとは関係ないですがライフサイクルとかwatch*を説明していきましょう。 結論、importしろ。これだけです。 ライフサイクル // optionsApi export default { created() { console.log("created"); }, mounted() { console.log("mounted"); }, }; // compositionApi import { onMounted } from "vue"; export default { setup() { console.log("created"); onMounted(() => { console.log("mounted"); }); }, }; それぞれのライフサイクルに対応したonXXX関数を使用する beforeCreate・createdがsetupにまとめられている computed //optionsApi export default { data() { return { count: 1, }; }, computed: { doubleCount() { return this.count * 2; }, }, }; //compositionApi import { ref, computed } from "vue"; export default { setup() { const countRef = ref(1); // computed関数を使用して計算プロパティを定義します const doubleCount = computed(() => { return countRef.value * 2; }); return { countRef, doubleCount, }; }, }; watch //optionsApi export default { data() { return { count: 1, }; }, watch: { count(count, prevCount) { console.log(count); console.log(prevCount); }, }, }; //compositionApi import { ref, watch } from "vue"; export default { setup() { const countRef = ref(1); // watch関数でリアクティブな変数を監視します watch(countRef, (count, prevCount) => { console.log(count); console.log(prevCount); }); return { countRef, }; }, }; watchの第1引数には監視対象、第2引数にはcallback関数を渡します。 callback関数の第1引数には変更後の値、第2引数には変更前の値が渡されます。 画像アップローダー ここからはフォームコンポーネントの続きをやっていきます。 パスワードとテキストエリアに関してはテキストボックスと同じような作りなので省略します。 optionsApi App.vue <template> <div class="contents"> <div class="imgContent"> <ImagePreview :imageUrl="imageUrl" /> <div class="module--spacing--largeSmall"></div> <UploadFile @fileList="setFileList" /> </div> </div> </template> <script> import ImagePreview from "./components/ImagePreview.vue"; import UploadFile from "./components/UploadFile.vue"; export default { components: { ImagePreview, UploadFile, }, data() { return { imageUrl: "", fileList: null, }; }, methods: { setFileList(fileList) { this.fileList = fileList; const imageUrl = URL.createObjectURL(fileList[0]); this.imageUrl = imageUrl; }, } }; </script> <style> @media screen and (min-width: 1026px) { .imgContent { width: 90%; max-width: 700px; height: 35vh; margin: auto; margin-bottom: 10px; background-color: #ccc; padding-top: 5%; } } @media screen and (min-width: 482px) and (max-width: 1025px) { .imgContent { width: 90%; max-width: 700px; height: 20vh; margin: auto; margin-bottom: 10px; background-color: #ccc; padding-top: 5%; } } </style> UploadFile.vue <template> <label for="corporation_file" class="btn btn-success"> 画像を設定する <input type="file" class="file_input" style="display:none;" id="corporation_file" mulitple="multiple" @change="onDrop" /> </label> </template> <script> export default { methods: { onDrop(e) { const imageFile = e.target.files; if(imageFile) { this.$emit("fileList", imageFile); } }, }, }; </script> <style scoped> label { background-color: #fff; padding: 1%; width: 40%; margin: 0 auto; box-shadow: 1px 1px 8px 0px #000; display: block; } </style> ImagePreview.vue <template> <div class="imagePreview"> <img :src="this.imageUrl" width="50" height="50" alt /> </div> </template> <script> export default { props: ["imageUrl"], }; </script> <style scoped> @media screen and (min-width: 1026px) { .imagePreview { height: 200px; width: 200px; background: rgb(240, 240, 240); overflow: hidden; border-radius: 50%; background-position: center center; background-size: cover; margin-left: auto; margin-right: auto; margin-bottom: 20px; position: relative; } .imagePreview img { height: 200px; width: 200px; } } @media screen and (min-width: 482px) and (max-width: 1025px) { .imagePreview { height: 100px; width: 100px; background: rgb(240, 240, 240); overflow: hidden; border-radius: 50%; background-position: center center; background-size: cover; margin-left: auto; margin-right: auto; margin-bottom: 20px; position: relative; } .imagePreview img { height: 100px; width: 100px; } } @media screen and (max-width: 481px) { .imagePreview { height: 50px; width: 50px; background: rgb(240, 240, 240); overflow: hidden; border-radius: 50%; background-position: center center; background-size: cover; margin-left: auto; margin-right: auto; margin-bottom: 20px; position: relative; } .imagePreview img { height: 50px; width: 50px; } } </style> compositionApi App.vue <template> <div class="imgContent"> <ImagePreview :imageUrl="form.imageUrl" /> <div class="module--spacing--largeSmall"></div> <UploadFile @fileList="setFileList" /> </div> </template> <script> import { reactive } from "vue"; import ImagePreview from "./components/ImagePreview.vue"; import UploadFile from "./components/UploadFile.vue"; export default { name: "App", ImagePreview, UploadFile, }, setup() { const form = reactive({ imageUrl: "", fileList: null, }); const setFileList = (fileList) => { form.fileList = fileList; const imgUrl = URL.createObjectURL(fileList[0]); form.imageUrl = imgUrl; }; return { form, setFileList, }; }, }; </script> <style> @media screen and (min-width: 1026px) { .imgContent { width: 90%; max-width: 700px; height: 35vh; margin: auto; margin-bottom: 10px; background-color: #ccc; padding-top: 5%; } } @media screen and (min-width: 482px) and (max-width: 1025px) { .imgContent { width: 90%; max-width: 700px; height: 20vh; margin: auto; margin-bottom: 10px; background-color: #ccc; padding-top: 5%; } } </style> UploadFile.vue <template> <label for="corporation_file" class="btn btn-success"> 画像を設定する <input type="file" class="file_input" style="display: none" id="corporation_file" mulitple="multiple" @change="onDrop" /> </label> </template> <script> export default { emits: ["fileList"], setup(_, context) { const onDrop = (e) => { const imageFile = e.target.files; if (imageFile) { context.emit("fileList", imageFile); } }; return { onDrop }; }, }; </script> <style scoped> label { background-color: #fff; padding: 1%; width: 40%; margin: 0 auto; box-shadow: 1px 1px 8px 0px #000; display: block; } </style> ImagePreview.vue <template> <div class="imagePreview"> <img :src="imageUrl" alt /> </div> </template> <script> export default { props: { imageUrl: { type: String, required: true, }, }, }; </script> <style scoped> @media screen and (min-width: 1026px) { .imagePreview { height: 200px; width: 200px; background: rgb(240, 240, 240); overflow: hidden; border-radius: 50%; background-position: center center; background-size: cover; margin-left: auto; margin-right: auto; margin-bottom: 20px; position: relative; } .imagePreview img { height: 200px; width: 200px; } } @media screen and (min-width: 482px) and (max-width: 1025px) { .imagePreview { height: 100px; width: 100px; background: rgb(240, 240, 240); overflow: hidden; border-radius: 50%; background-position: center center; background-size: cover; margin-left: auto; margin-right: auto; margin-bottom: 20px; position: relative; } .imagePreview img { height: 100px; width: 100px; } } @media screen and (max-width: 481px) { .imagePreview { height: 50px; width: 50px; background: rgb(240, 240, 240); overflow: hidden; border-radius: 50%; background-position: center center; background-size: cover; margin-left: auto; margin-right: auto; margin-bottom: 20px; position: relative; } .imagePreview img { height: 50px; width: 50px; } } </style> セレクトボックス optionsApi App.vue <template> <div class="contents"> <SelextBox v-model="select" :options="optionsSelect" /> <span v-if="this.select == ''">選択オプション:選択してください。</span> <span v-else>選択オプション: {{ select }}</span> </div> </template> <script> import SelextBox from "./components/SelectBox.vue"; export default { components: { SelextBox, }, data() { return { select: 0, selectValue: "", optionsSelect: [ { label: "Vue.js", value: "Vue.js" }, { label: "React", value: "React" }, { label: "Angular", value: "Angular" }, ], }; }, }; </script> SelectBox.vue <template> <select name="select-box" @input="updateValue"> <option value="0">選択してください</option> <option v-for="(option, index) in options" :key="index" :value="option.value" >{{ option.label }}</option > </select> </template> <script> export default { props: { options: { type: Array, required: true }, }, methods: { updateValue(e) { this.$emit("input", e.target.value); }, }, }; </script> compositionApi App.vue <template> <SelectBox v-model:select="form.select" name="selectbox" :options="form.optionsSelect" /> <div class="module--spacing--verySmall"></div> <span v-if="form.select == ''">選択オプション:選択してください。</span> <span v-else>選択オプション: {{ form.select }}</span> </template> <script> import { reactive } from "vue"; import SelectBox from "./components/SelectBox.vue"; export default { name: "App", components: { SelectBox, }, setup() { const form = reactive({ select: 0, selectValue: "", optionsSelect: [ { label: "Vue.js", value: "Vue.js" }, { label: "React", value: "React" }, { label: "Angular", value: "Angular" }, ], }); return { form, }; }, }; </script> SelectBox.vue <template> <select :name="name" @input="updateValue"> <option value="0">選択してください</option> <option v-for="(option, index) in options" :key="index" :value="option.value" > {{ option.label }} </option> </select> </template> <script> export default { props: { name: { type: String, required: true }, options: { type: Array, required: true }, }, emits: ["update:select"], setup(_, context) { const updateValue = (e) => { context.emit("update:select", e.target.value); }; return { updateValue }; }, }; </script> ラジオボタン optionsApi App.vue <template> <div class="contents"> <RadioButton v-model="checkName" :options="optionsRadio" /> <span>選択オプション: {{ checkName }}</span> </div> </template> <script> import RadioButton from "./components/RadioButton.vue"; export default { components: { RadioButton, }, data() { return { checkName: "選択してね", optionsRadio: [ { label: "hoge", value: "hoge" }, { label: "bow", value: "bow" }, { label: "fuga", value: "fuga" }, ], }; }, }; </script> RadioButton <template> <div> <label v-for="(option, index) in options" :key="index"> <!-- ラジオボタンにはname属性必須 --> <input type="radio" :value="option.value" @change="updateValue" name="radio-button" />{{ option.label }}</label > </div> </template> <script> export default { props: { options: { type: Array, required: true }, }, methods: { updateValue(e) { this.$emit("input", e.target.value); }, }, }; </script> compositionApi App.vue <template> <RadioButton v-model:modalValue="form.checkName" :options="form.optionsRadio" /> <span>選択オプション: {{ form.checkName }}</span> </template> <script> import { reactive } from "vue"; import RadioButton from "./components/RadioButton.vue"; export default { name: "App", components: { RadioButton, }, setup() { const form = reactive({ checkName: "選択してね", optionsRadio: [ { label: "hoge", value: "hoge" }, { label: "bow", value: "bow" }, { label: "fuga", value: "fuga" }, ], }); return { form, }; }, }; </script> RadioButton.vue <template> <div> <label v-for="(option, index) in options" :key="index"> <!-- ラジオボタンにはname属性必須 --> <input type="radio" :value="option.value" @change="updateValue" name="radio-button" />{{ option.label }}</label > </div> </template> <script> export default { props: { options: { type: Array, required: true }, }, emits: ["update:modalValue"], setup(_, context) { const updateValue = (e) => { context.emit("update:modalValue", e.target.value); }; return { updateValue }; }, }; </script> ボタン チェックボックスにチェックを入れないとボタンがクリックできなくて、 そのボタンをクリックしたらモーダルが出現するものを実装します。(説明下手でごめんなさい。) optionsApi App.vue <template> <div class="contents"> <CheckBox v-model="checked" :checked="checked" /> <label>同意</label><br /> <Button :disabled="!checked" msg="モーダルが出ます" @push="click" /> <Modal title="モーダルタイトル" detail="モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。" v-if="open" @close="open = false" @modal-click="modalClick" /> </div> </template> <script> import CheckBox from "./components/CheckBox.vue"; import Button from "./components/Button.vue"; import Modal from "./components/Modal.vue"; export default { components: { CheckBox, Button, Modal, }, data() { return { checked: false, open: false, }; }, methods: { click() { this.open = true; }, modalClick() { console.log("テキストボックスの入力内容:", this.text); console.log("パスワードの入力内容:", this.pass); console.log("テキストエリアの入力内容:", this.textarea); console.log("画像の名前:", this.fileList[0].name); alert("コンソールを見ろ!!"); this.open = false; this.checked = false; // this.uploadImage(); }, }; </script> CheckBox.vue <template> <input type="checkbox" @change="updateValue" :checked="this.checked" /> </template> <script> export default { props: ["checked"], methods: { updateValue(e) { this.$emit("input", e.target.checked); }, }, }; </script> Button.vue <template> <button class="button" @click="push"> {{ this.msg }} </button> </template> <script> export default { props: ["msg"], methods: { push() { this.$emit("push"); }, }, }; </script> Modal.vue <template> <transition name="modal"> <div class="overlay" @click="$emit('close')"> <div class="panel" @click.stop> <h2>{{ this.title }}</h2> <div class="module--spacing--small"></div> <div class="modal-contents"> <p>{{ this.detail }}</p> </div> <Button :disabled="false" msg="ボタン" @push="click" /> </div> </div> </transition> </template> <script> import Button from "./Button.vue"; export default { props: ["title", "detail"], components: { Button, }, methods: { click() { this.$emit("modal-click"); }, }, }; </script> <style scoped> .overlay { background: rgba(0, 0, 0, 0.8); position: fixed; width: 100%; height: 100%; left: 0; top: 0; right: 0; bottom: 0; z-index: 900; transition: all 0.5s ease; } .panel { width: 40%; text-align: center; background: #fff; padding: 40px; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } .modal-contents { text-align: left; } .modal-enter, .modal-leave-active { opacity: 0; } .modal-enter .panel, .modal-leave-active .panel { top: -200px; } </style> compositionApi App.vue <template> <CheckBox v-model:change="form.checked" :checked="form.checked" /> <label>同意</label><br /> <Button :disabled="!form.checked" msg="モーダルが出ます" @push="handleOpen" /> <Modal title="モーダルタイトル" detail="モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。モーダルの内容です。" v-if="form.open" @close="form.open = false" @modal-click="modalClick" /> </template> <script> import { reactive } from "vue"; import CheckBox from "./components/CheckBox.vue"; import Button from "./components/Button.vue"; import Modal from "./components/Modal.vue"; export default { name: "App", components: { CheckBox, Button, Modal, }, setup() { const form = reactive({ checked: false, open: false, }); const handleOpen = () => { form.open = true; }; const modalClick = () => { aleat("モーダルの中のボタンをクリックしました") }; return { form, handleOpen, modalClick }; }, }; </script> CheckBox.vue <template> <input type="checkbox" @change="updateValue" :checked="checked" /> </template> <script> export default { model: { prop: "checked", event: "change", }, props: { checked: { type: Boolean, required: true, }, }, emits: ["update:change"], setup(_, context) { const updateValue = (e) => { context.emit("update:change", e.target.checked); }; return { updateValue }; }, }; </script> Button.vue <template> <button class="button" @click="push"> {{ msg }} </button> </template> <script> export default { props: { msg: { type: String, required: true, }, }, emits: ["push"], setup(_, context) { const push = () => { context.emit("push"); }; return { push }; }, }; </script> Modal.vue <template> <transition name="modal"> <div class="overlay" @click="handleClose"> <div class="panel" @click.stop> <h2>{{ title }}</h2> <div class="module--spacing--small"></div> <div class="modal-contents"> <p>{{ detail }}</p> </div> <Button :disabled="false" msg="ボタン" @push="click" /> </div> </div> </transition> </template> <script> import Button from "./Button.vue"; export default { props: { title: { type: String, required: true, }, detail: { type: String, required: true, }, }, components: { Button, }, emits: ["close", "modal-click"], setup(_, context) { const handleClose = () => { context.emit("close"); }; const click = () => { context.emit("modal-click"); }; return { handleClose, click }; }, }; </script> <style scoped> .overlay { background: rgba(0, 0, 0, 0.8); position: fixed; width: 100%; height: 100%; left: 0; top: 0; right: 0; bottom: 0; z-index: 900; transition: all 0.5s ease; } .panel { width: 40%; text-align: center; background: #fff; padding: 40px; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } .modal-contents { text-align: left; } .modal-enter, .modal-leave-active { opacity: 0; } .modal-enter .panel, .modal-leave-active .panel { top: -200px; } </style> まとめ 以上。 フォームコンポーネントを作りながらcompositionApiについて書かせていただきました。 ご参考になれば幸いです。 もし間違いなどがあれば、ご教授お願いします。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Mokurenのアーキテクチャを再考案してみた(クリーンアーキテクチャ参考)

個人開発でMokurenというChrome拡張を作っています。最近、アーキテクチャを再設計する機会があり折角なので情報共有ということでQiita の記事にしてみようと思います。 技術周り 使っている技術周りです。 chrome extension Vue.js TypeScript Apollo Firebase Mokurenとは この記事を読み進めていくにあたって、Mokurenというプロダクトを知っている必要はないですが一応紹介させてください。 MokurenはGitHubのIssueを扱いやすくするChrome拡張です。リリースした記事も書いてあるのでよければ読んでください 今までのコード では本題に入っていきます。今回は名前を入力して、ラベルを作成するコードを例にしてみます。inputに名前を記載して、@clickしたらラベルが作成される仕様です。 とりあえず作ってみちゃおうで始まる個人開発ではよく見る光景ではないでしょうか??(流石にここまで酷いのはないか) このコードの良くない部分はcreateLabelメソッドがいろんな情報を知りすぎている点です。 Apolloを使うことを知っている どのMutationを使うか知っている パラメータの形式を知っている レスポンスの形を知っている AppLabelモデルを作成する方法を知っている こう言ったコードで起こりうるのは1.再利用しずらい、2.テスト書きづらい、3.仕様の変更に弱いなどがあると思います。 このコードをベースとしてアーキテクチャを再設計してみたいと思います。 ダメな例 <template> <div> <input v-model="name"></input> <button @click="createLabel">create</button> </div> </template> <script> import { Component, Vue } from 'vue-property-decorator'; @Component({ components: {}, }) export default class AppView extends Vue { name: string = ""; async createLabel(): Promise<void> { const params = { name: "name", } try { // ※ラベル作成APIを叩く処理は他の部分でも使い回す可能性がある const res = await apolloClient.mutate({ mutation: CreateLabelMutation, variables: params, }); // ※レスポンスからAppLabelを作成するコードは他にもあるはず const newLabel = new AppLabel(name: res.name, id: res.id); // 成功のダイアログを表示 Message.success("created ${newLabel.name}"); } catch(e) { Message.error("faild create label"); } } } </script> プログラミングに求めるもの アーキテクチャーを考え直す前に、プログラミングに求めるものを考えてみました。 普段、実装する中で3つのことを気にかけています。 テストが書きやすい。自分以外の人が使うことを前提としているため、バグだらけでは良いプロダクトとは言えません。そのためにもテストが書きやすい必要があります 仕様の変更への強さ。作って終わりのプロダクトでなければ、プロダクトの改修についていけるコードではないといけません。 実装の速さ。プロダクトとして仮説検証のサイクルを早くしていきたいため、実装の速さも求められます。 1や2が大事なのは言うまでもないですが、個人開発とは言え3の実装の速さも大事だと思っています。コードを書いていればお金がもらえる普段の仕事とは違い、ユーザーからのフィードバックが得られるかわからない状況の中作り続ける個人開発はモチベーションとの戦いでもあるため、早くユーザーへ届けることも大事となります。 再設計しました 再設計をするにあたってクリーンアーキテクチャを採用しました。 しかし、そのまま使うのではなく、不要な部分は省き、逆に足りないところは足しています。 今回は重要なところだけ絞って説明していきます。 (クリーンアーキテクチャについてはこちらがわかりやすかったです) Vueインスタンス Vueインスタンスはextends Vueしている部分です。先程のダメなコード例で紹介した部分ですが、知っている情報を絞って役割を明確にします。今回の役割は、 サービスロジックを呼び出す。クリーンアーキテクチャのController。 データの加工。クリーンアーキテクチャのPresenter。 Storeのdispatch です。 VueインスタンスはクリーンアーキテクチャでいうところのPresenterとControllerの役割を担っています。またStoreのdispatchを実行する役割を持っています。 なぜ、クリーンアーキテクチャでいうところのPresenterとControllerを別で実装しないかというと、そのコストに合わないと感じたからです。 <template> <div> <input v-model="name" /> <button @click="createLabel">create</button> </div> </template> <script> import { Component, Vue } from 'vue-property-decorator'; @Component({ components: {}, }) export default class AppView extends Vue { name: string = ""; async createLabel(): Promise<void> { inputData = new LabelCreateInputData(this.name); const interactor = inject(Keys.LabelUseCaseKey) try { const newLabel = await interactor.create(inputData); Message.success("created ${newLabel.name}"); } catch(e) { Message.error("faild create label"); } } } <script> InputBoundary & Interactor クリーンアーキテクチャのInputBoundaryとInteractorです。 ここではサービスロジックを扱います。 このあと紹介するRepositoryのインターフェイス参照することで、テスト時に厄介となるAPIのモックを簡単に扱えるようにしています。 export interface LabelUseCase { async create(inputData: LabelCreateInputData): Promise<AppLabel>; } export class LabelInteractor implements LabelUseCase { // インターフェイスを参照 constructor(readonly labelRepository: ILabelRepository) {} async create(inputData: LabelCreateInputData): Promise<AppLabel> { const params = new LabelCreateApiParams({name: inputData.name}); const label = await this.labelRepository.create(params); return label; } } Repository & RepositoryParam クリーンアーキテクチャのGatewayに当たる部分です。 Mokurenでは諸事情により、Rails、Firebase、GitHub APIを使っています。APIの参照先をRepositoryで隠すことによってサービスロジックでどのAPIかを意識せずにすみます。こうすることで、例えばAPIの参照先が変わったとしても変更するのはRepositoryだけで済みます。 (ちなみにMokurenでも、段々とFirebaseからRailsに移行していきたいのです) export interface ILabelRepository { async create(params: LabelCreateApiParams): Promise<AppLabel>; } export class LabelRepository implements ILabelRepository { async create(params: LabelCreateApiParams): Promise<AppLabel> { const res = await apolloClient.query({ query: CreateLabelMutation, variables: params.toMap(), }); return LabelFactory.fromQueryRes(res); } } export class LabelCreateApiParams implements ApiParams { constructor(readonly name: string) {} toMap(): any { return { "name": name, }; } } 実際のディレクトリ 実際のディレクトリはこんな感じにあるかと思います。 ちなみにViewとcomponentsはどちらも.vueファイルが置かれるのですが、その使い分けは View→完全自己完結型(親からは何も受け取らず自身のView+ViewModelで全て完結するもの。propsを受け付けない。) components→半自己完結型(必須パラメータのみを親から受け取り、決まった処理を行う共通パーツ。) でわける想定です。詳しくはこちらを参考にしています。 - content_script - background - repository - label - interface_label_repository - inmemory_label_repository - label_repository - view - component - usecase - label - interface_label_usecase - label_usecase - input_data - entity - label - label - api_client(factory?) テストはどこまで書くか? Interfaceを使うことでテストが書きやすくなります。とは言え、全てのコードにテストを書いていたらキリがないです。 ではどこをテスト書けば良いでしょうか。 自分の意見としては、Interactor。なぜなら一番変更が多いから。あとViewとかは画面を見ながら実装するからバグに気付きやすいけど、Interactorは気づかないケースも多いかもです。 データを扱うサービスとして大事なのはデータの不整合がないこと。サーバーはデータを扱うことが多いのでテストをしっかり書くが、フロントはサボりがち。Interactorに関してはデータを扱うこともあるのでしっかり書くべきかなと思いました。 参考 実装クリーンアーキテクチャ クリーンアーキテクチャ完全に理解した Vue.jsでComposition APIを使ってクリーンアーキテクチャ Blog
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravel BreezeでVue2を使うまでの話

今年初開催のビットスター Advent Calendar 2021 1日目です。 明日は@kansaizineさんのさくらのクラウドで出来るだけ安価にバックアップサーバを運用する工夫です。 はじめに 認証やフロントエンドのセットアップなどするためlaravel uiなどをいままで使用していたのですが、laravel8からはLaravel BreezeとLaravel Jetstreamが推奨されており、現状最新バージョンでVue.jsを指定してインストールコマンドを実行するとVue3がデフォルトで入ってきます。 ただほかのライブラリなどがまだVue3に対応していないなど様々な理由でVue2で開発せざる得ないなど理由がある場合にどうしたかを備忘録的に書いていきます。 ちなみにBreezeは最初からVue3で作成されており、Jetstreamはv2.2からVue3に対応したようでJetstream v2.2を入れるという手もあると思いますが、依存関係で引っかかってしまうので下記に書いていくやり方がいいかと思います。 環境 PHP v8.0.13 Laravel v8.74.0 内容 Breezeのインストール composer require laravel/breeze --dev php artisan breeze:install vue npm run dev ドキュメントを参考にLaravel Breezeをインストールし、Vue.jsでインストールを実行します。 この段階ではVue3になっています。 vue2_install npm uninstall @inertiajs/inertia-vue3 --save-dev npm install vue@"^2.6.0" --save-dev npm install @inertiajs/inertia-vue --save-dev npm install vue-template-compiler --save-dev npm install portal-vue --save-dev npm install vue-loader@"^15.9.8" --save-dev Vue2用にパッケージを入れ替えます。 こちらを参考にしています。 上記を参考にresources/js/app.jsを書き換える 各コンポーネント差分 -import { Link } from '@inertiajs/inertia-vue3'; +import { Link } from '@inertiajs/inertia-vue'; inertia-vue3を削除したので、各コンポーネントでを呼び出している箇所をinertia-vueに変更します。 この状態でnpm run devを実行するとルートエレメントが複数あるとエラーが発生するので各ページ下記のような修正をします。 差分 diff --git a/resources/js/Pages/Welcome.vue b/resources/js/Pages/Welcome.vue index 33dbf28..8c04ad5 100644 --- a/resources/js/Pages/Welcome.vue +++ b/resources/js/Pages/Welcome.vue @@ -1,4 +1,5 @@ <template> +<div> <Head title="Welcome" /> <div class="relative flex items-top justify-center min-h-screen bg-gray-100 dark:bg-gray-900 sm:items-center sm:pt-0"> @@ -110,6 +111,7 @@ </div> </div> </div> +</div> </template> <style scoped> 最後にnpm run devでエラーが発生しなければ大丈夫かと思います。 package.json 参考までにpackage.jsonを下記に記載しておきます。 package.json { "private": true, "scripts": { "dev": "npm run development", "development": "mix", "watch": "mix watch", "watch-poll": "mix watch -- --watch-options-poll=1000", "hot": "mix watch --hot", "prod": "npm run production", "production": "mix --production" }, "devDependencies": { "@inertiajs/inertia": "^0.10.0", "@inertiajs/inertia-vue": "^0.7.2", "@inertiajs/progress": "^0.2.6", "@tailwindcss/forms": "^0.2.1", "@vue/compiler-sfc": "^3.0.5", "autoprefixer": "^10.2.4", "axios": "^0.21", "laravel-mix": "^6.0.6", "lodash": "^4.17.19", "portal-vue": "^2.1.7", "postcss": "^8.2.13", "postcss-import": "^14.0.1", "tailwindcss": "^2.1.2", "vue": "^2.6.14", "vue-loader": "^15.9.8", "vue-template-compiler": "^2.6.14" } } 最後に まだVue2で開発する必要があって、調べても海外のフォーラムでざっくりとしか書いておらず微妙に詰まったので備忘録的に残しておきました。 BreezeやJetstreamでVue2でもインストールできるオプションがあればうれしいのですが需要ないんでしょうか。時期が悪いというか時が解決するような気もしますが...
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Vue.js 入門】Vueの特徴

Vue.jsの特徴 段階的に拡張できる。 プログレッシブ(1ページからnページまで段階的に作れる。) コンポーネント分割 メンテしやすい SPAが作れる ユーザービリティ向上 インストール(CDN) CDNリンク ここでは2.6.11を指定してhead内に保存している。 Hello Vue!と表示されれば成功。 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.js"></script> </head> <body> <div id="app"> {{ message }} </div> <script> let app = new Vue({ el: '#app', data(){ return{ message:'Hello Vue!' } } }) </script> </body> </html> API(elとdata) Vueをインスタンス化した時のelやdataなどのことをAPIをいう。 API一覧 <div id="app"> //elで指定した仮想DOMの範囲 {{ message }} //dataで指定したkeyをマスタッシュで囲む。 </div> <script> let app = new Vue({ el: '#app', //仮想DOMの範囲を指定する。 data(){ //elで指定した仮想DOMの範囲でオブジェクト形式でデータをセットする。 return{ message:'Hello Vue!' } } }) </script> 仮想DOM DOM(Dockument Object Model) HTMLをプログラムから操作できる仕組み DOMはツリー状の構造となっている。 以下、検証ツールで確認することもできる。 const html = document.querySelector('html') console.dir(html) 仮想DOM 旧DOM(変更前)と新DOM(変更後)のDOMを比較し、変更があった差分だけを実際のDOMに反映する。 処理が速くなるメリットがある。 参考 【Vue.js2&Vue.js3対応】基礎から【Vuetify】を使った応用まで! 超初心者から最短距離でレベルアップ vue.js公式リファレンス
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue Scoped Slotで親から子のメソッドに引数を渡し発火させる

パーソンリンク アドベントカレンダー5日目です!? 環境 Vue.js2系 きっかけ slotする親からVueコンポーネントに情報伝達がしたいケースがありました。 slotする内容でなければemitすれば良いだけの話ですが、このケースはそうはいきません。 具体的にいうと、slotしたDOM内のbutton要素がクリックされた時、 子コンポーネントのイベントを引数を渡して発火させたかったのです。 親コンポーネント <sample-component> <form> <input type="text" id="keyword> <button v-on:click="clickEvent(data)">実行</button> </form> </sample-component> 子コンポーネント <slot v-bind:on="slotOn"></slot> function slotOn(data) { // dataを受け取り何らかの処理 } 対処結果 この場合は、scoped slotとactivatorを使います。 親コンポーネント <sample-component> <template v-slot:activator="{ on }"> <form> <input type="text" id="keyword> <button v-on:click="clickEvent(on)">実行</button> </form> </template> </sample-component> function clickEvent(on) { return on(data); } 子コンポーネント <slot name="activator" v-bind:on="slotOn"></slot> function slotOn(data) { // dataを受け取り何らかの処理 } こうすることで、slotの内容で発火するイベントから、子コンポーネント内の slotOn 関数を発火させ、尚且つ引数も渡すことができます。 解説 activator v-slot:activatorは名前付きスロットの書き方です。 activatorという名前のスロットを作成しています。 ではv-slot:activator="{ on }"は何をしているのでしょうか。 これは、スロットactivatorからonというプロパティを取得して、 プロパティonをonという変数名に代入しています(分割代入)。 そしてonの中身は、この例で言うと子コンポーネントで指定したslotOnメソッドになります。 function slotOn(data) { // dataを受け取り何らかの処理 } Scoped Slot これらの機能を実現するのがVueのScoped Slot(スコープ付きスロット)です。 親コンポーネント内でスロットコンテンツとして子コンポーネントからプロパティを使えるようにするには、 slot要素の属性に使いたいプロパティをバインドします。 <slot name="activator" v-bind:on="slotOn"></slot> slot要素にバインドされた属性はスロットプロパティと呼ばります。 親スコープ内でv-slotの値として名前を指定することで、スロットプロパティを受け取ることができます。 参考 スコープ付きスロット v-slot:activator="{ on }"の仕組みの考察
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Nuxt2(composition-api)でGoogle MapとMakerを表示

とりあえず備忘録。 APIキー取得 APIをMaps Javascript APIを許可してKEYを取得。 .ENVに追加して、nuxt.configでpublicRuntimeConfigに設定 const config = { publicRuntimeConfig: { gmapAPIKey: process.env.GMAP_API_KEY, }, // : ライブラリ 公式に沿って、mapとmakerのライブラリを追加 npm i -D @googlemaps/js-api-loader @googlemaps/markerclusterer typeを追加 tsconfig.json { "compilerOptions": { "types": [ "@googlemaps/js-api-loader", "@googlemaps/markerclusterer"  // : vueファイル作成 とりあえずthis.$refs代わりの空refを追加して、全画面表示になるようにCSSを設定。ライブラリも読み込む index.vue <template> <div ref="googleMapEl" class="googlemap" /> </template> <script lang="ts"> import { defineComponent, ref } from '@nuxtjs/composition-api' import { Loader } from '@googlemaps/js-api-loader' import { MarkerClusterer } from '@googlemaps/markerclusterer' export default defineComponent({ setup () { const googleMapEl = ref() return { googleMapEl } } }) </script> <style scoped> .googlemap { position: fixed; top: 0; left: 0; right: 0; bottom: 0; } </style> Loaderの作成 contextから、configで設定したAPIキーを呼びだし、setup内でLoaderインスタンスを作成 setup () { const context = useContext() const { $config } = context const loader = new Loader({ apiKey: $config.gmapAPIKey }) } 各初期化 const GOOGLE = ref() onMounted(() => { createMapLoader() }) const createMapLoader = () => { loader.load().then((google) => { GOOGLE.value = google initMarker() initMap() }).catch(e => {}) }  MAPの初期化 onMountedでマップを初期化。 loader使って,マップを新規に作成。ここで作った値をmaker側でも使うので、setup内で使えるようにしとく template内のgoogleMapに描写。 maker用のインスタンスもついでに初期化 const MAP = ref() const initMap = () => { const mapOptions = { center: { lat: 35.6769883, lng: 139.7588499 }, zoom: 12 } MAP.value = new GOOGLE.value.maps.Map(googleMapEl.value, mapOptions) } マーカーを初期化 空配列でマーカー用のクラスターを生成しとく const markerClusterer = ref() const initMarker = () => { const markers = [] as any[] markerClusterer.value = new MarkerClusterer({ markers, map: MAP.value }) } onClickでマーカーを追加 const onClick = () => { const marker = new GOOGLE.value.maps.Marker({ position: { lat: 35.6769883, lng: 139.7588499 } }) markerClusterer.value.addMarker(marker) } マーカー自体をonClickでマーカー削除 マーカーを作成するときに、自滅用イベントを追加 const onClick = () => { const marker = new GOOGLE.value.maps.Marker({ position: { lat: 35.6769883, lng: 139.7588499 } }) // 上記削除イベントを追加 marker.addListener("click", () => { markerClusterer.value.removeMarker(marker) }) markerClusterer.value.addMarker(marker) }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

非同期通信をするなら絶対にやったほうがいいこと

TL; DR ユーザーを待たせるとき、「どのように待たせるか」によって印象は結構変わります。 非同期処理などでユーザーを待たせるときは、適切なローディングを表示してUXを改善しましょう。 違いを体感していただけるよう、いくつかの例を元に書いてみました。 ボタン押下の例 送信ボタンを押してから、通信に1秒かかるお問い合わせフォームを作ってみました。 2つの例を比べてみて下さい。 実際には何も通信しないダミーのFORMなので、気軽に試して下さい。 Bad See the Pen ダミーFORM by laineus (@laineus) on CodePen. Good See the Pen ダミーFORM by laineus (@laineus) on CodePen. 改善されたこと1 ユーザーの操作に対し、画面が即時応答するようになりました。 結果: 体感速度が向上しました。 どちらの例も、目的が達成されるのは1秒後であるにも関わらず、 「押して即何かが起きる」というステップを踏むだけで、サクサク動いている印象を受けます。 ローディングが無い場合は、ボタン押下してから通信が完了するまで、 画面に何も変化がないため、フリーズしているような、もっさりした印象を受けます。 例えば、頑張ってこの1秒かかる通信を0.8秒くらいに高速化するより、体感できる差は大きいと思われます。 改善されたこと2 ユーザーは通信中であるということが分かるようになりました。 結果: 1秒という待ち時間に対する印象が良くなりました。 ローディング 印象 無し 「通信中か?」「まだかな?」「もしかしてちゃんと送信ボタン押せなかった?」「もう一回押すか」 有り 「ああ、通信中ね。待とう。」 やはり同じ秒数であっても、ユーザーが感じるストレスは大きく異なると思います。 (多重送信の防止もセットで見直しましょう) SPAにおける画面遷移の例 もう1つ、画面遷移における例も用意したので、これも試しみていただきたいです。 一覧画面と詳細画面を持つブログのようなサイトです。 一覧と詳細を行ったり来たりして、どちらが快適かどうか比べてみて下さい。 今回はどちらの例でもローディングが表示されます。 違いは、遷移元の画面と遷移先の画面のどちらでローディングを表示するかです。 詳細画面の通信にかかる時間は、どちらも同じ1秒です。 元画面でローディング See the Pen 画面遷移 by laineus (@laineus) on CodePen. 遷移先でローディング See the Pen 画面遷移 by laineus (@laineus) on CodePen. 比較 みなさんは、どちらが快適に感じたでしょうか? 僕的には、どちらかと言うと後者のほうがサクサク動く感じがします。 どちらも遅いのは間違いないですが、感覚的に言うと、 前者: 全体的に重い 後者: サイトのUIは快適だけど通信が遅い みたいな…? 何故か ユーザーが僕だとすると、 ユーザーの欲求は「記事の本文を見たい」ですが、 記事押下に対して画面に期待する動作は「ページが遷移する」です。 ユーザーは「記事を押すと詳細ページに遷移するだろう」と期待しているわけです。 後者の例では、画面がユーザーの期待に即時応えるため、 たとえそれが要求を満たさない不完全なものであっても、 サイトのUI自体は自分の操作にサクサク応じてくれる印象を受けます。 前者の例では、ローディングのバーは即時表示されるものの、それは期待動作ではありません。 期待動作が満たされるのは1秒後です。 → → → → 元画面でロード 記事押下 Loading... UI期待動作 & 要求達成 遷移先でロード 記事押下 UI期待動作 Loading... 要求達成 ちなみに前者の例は、Nuxtのデフォルトのローディング(?)を真似たものです。 Nuxt製のサイトって、SPAなのに操作がもっさりした印象を持っていたんですが、それってスピードの問題よりも、UIの問題が大きいんじゃないかな?と思ったり。 非SPAサイトにおけるブラウザの基本的な挙動も前者に近いですね。 Chromeならタブにローディングを表示しつつ、HTMLのレスポンスがあった段階で遷移します。 サーバーサイドやネットワークのスピードが、UIの操作感に直に悪影響を与えてしまいます。 (後者は後者で、画面が未完成のまま表示されるので、嫌がる人も居るかもしれません) いいねボタン等の例 いいねボタンのようなケースでも、いいねボタン押下時にローディングを表示して、通信完了後に、いいねボタンを押下済みデザインに変更すればよいでしょうか? いいねボタンのようなケースでは、わざわざ通信完了を待つ意味が薄いので、実際にPOSTが完了していなかろうと、即時いいねボタンを押下済みデザインに差し替えてしまうのがいいですね。 ローディングを表示しないほうがいい(=そもそも何も待たせる必要がない)例の一つでした。 おわりに 最後に、タイトルには目を引くべく絶対にやったほうがいいとは書いたものの、 全てのケースで例で紹介したような対策を入れるべきと言いたいわけではありません。 同じ時間ユーザーを待たせるとき、どのように待たせるかで大分印象変わりますよね、と伝えたかったのがメインですので、待機時間のUIに少しでも意識を向けてもらえればいいかなと思います。 そのうえで、やるかどうかや、どうやるかはケースに応じて変えていただければと思います。 もしUI/UXデザイナーが居て、ここらへんを相談できるのであれば、恵まれた環境と思いますが、 各画面の静的なデザインデータだけで開発しなければいけないケースも珍しくありませんので、 そういったとき、エンジニアからも拾い上げたり、問題提起できれば、より良いプロダクトに近づけるんじゃないかと思います。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む