- 投稿日:2021-08-29T14:28:21+09:00
Editorjsにメンション機能を実装する
前提 制約がないのであれば、ライブラリを使用する方が効率がいい。どうしても自作する必要がある人やメンションの実装をしてみたい人に向けた共有記事です。 vue-mention 環境 Editorjs v2.22.2 Vue 2.6.14 Vuetify 2.5.8 コンセプト MessangerやSlackのように「@」の入力に対してメニューを表示し、選択したユーザーをメンションする。 設計 Editorjsのreadyを使いDom生成を検知、Editorjsのsaveを使い編集を検知 Editorjsの一番外枠にMutationObserverをつけて、ブロックの変更を検知する。 動的に追加されるDomでメンション可能なものにさらにMutationObserverをつけて監視 行頭やスペース後の@に対してメニューを表示 表示メニューの選択に応じてアンカータグ(メンション)追加 実装物 ArticleEditor.vue <template> <div :id="uniqueId" /> </template> <script> import EditorJS from "@editorjs/editorjs"; import Header from "@editorjs/header"; import LinkTool from "@editorjs/link"; import ImageTool from "@editorjs/image"; import CheckList from "@editorjs/checklist"; import List from "@editorjs/list"; import Embed from "@editorjs/embed"; import Quote from "@editorjs/quote"; import Delimiter from "@editorjs/delimiter"; import Table from "@editorjs/table"; import gb from "../../../mixins/global/gb"; export default { name: "ArticleEditor", mixins: [gb], props: { article: { type: Object, default: () => ({}) }, readOnly: { type: Boolean, default: false } }, data: () => ({ editor: {}, uniqueId: `_${gb.methods.generateId()}` }), mounted() { this.setEditor(); }, methods: { setEditor() { this.editor = new EditorJS({ data: this.article, placeholder: "テキストを入力...", autofocus: true, readOnly: this.readOnly, minHeight: 0, onChange: function() { this.save(); }.bind(this), holder: this.uniqueId, tools: { header: { class: Header, shortcut: "CMD+SHIFT+H", config: { placeholder: "ヘッダー", levels: [1, 2, 3, 4], defaultLevel: 3 } }, checklist: { class: CheckList, inlineToolbar: true }, list: { class: List, inlineToolbar: true }, embed: { class: Embed, config: { services: { youtube: true, twitter: true } } }, quote: { class: Quote, inlineToolbar: true, shortcut: "CMD+SHIFT+O", config: { quotePlaceholder: "テキストを入力", captionPlaceholder: "キャプションを入力" } }, delimiter: Delimiter, table: { class: Table, inlineToolbar: true, config: { rows: 2, cols: 3 } } }, i18n: { messages: { ui: { blockTunes: { toggler: { "Click to tune": "クリックして調整", "or drag to move": "ドラッグして移動" } }, inlineToolbar: { converter: { "Convert to": "変換" } }, toolbar: { toolbox: { Add: "追加" } } }, toolNames: { Text: "テキスト", Heading: "タイトル", List: "リスト", Checklist: "チェックリスト", Quote: "引用", Delimiter: "直線", Table: "表", Link: "リンク", Bold: "太字", Italic: "斜体", Image: "画像" }, blockTunes: { deleteTune: { Delete: "削除" }, moveUpTune: { "Move up": "上に移動" }, moveDownTune: { "Move down": "下に移動" } } } }, onReady: () => { this.$emit("ready", this.uniqueId); } }); }, save() { this.editor .save() .then(data => { this.$emit("saved", data); }) .catch(err => { console.log(err); }); }, clear() { this.editor.clear(); }, render(body) { this.editor.render(body); } } }; </script> EditorComponent.vue <template> <div> <v-menu absolute :position-x="mentionMenu.x" :position-y="mentionMenu.y" :value="mentionMenu.show" rounded > <v-list dense max-height="100"> <v-list-item v-for="user in mentionableUsers" :key="user.id" dense @click="insertMention(user)" > <v-list-item-avatar> <v-img v-if="user.picture" :src="user.picture" /> <v-icon v-else>mdi-account</v-icon> </v-list-item-avatar> <v-list-item-title>{{ user.name }}</v-list-item-title> </v-list-item> <v-list-item v-if="!mentionableUsers.length"> <v-list-item-subtitle> メンション可能な人がいません。 </v-list-item-subtitle> </v-list-item> </v-list> </v-menu> <div style="max-height: 500px" class="overflow-y-auto"> <ArticleEditor ref="editor" :article="article" @saved="updateArticle" @ready="setBlockObserver" /> </div> </div> </template> import { getUsers } from "../../../../helpers/User"; import ArticleEditor from "../../../themes/editor/ArticleEditor"; import gb from "../../../../mixins/global/gb"; <script> export default { name: "TimelinePostEditor", components: { ArticleEditor }, mixins: [gb], props: { article: { type: Object, default: () => ({}) }, mentions: { type: Array, default: () => [] } }, data: () => ({ mentionMenu: { x: 0, y: 0, show: false, selectedParagraphIndex: null }, users: [], editorId: "" }), watch: { article(article) { const mentionUserIds = []; if (!article.blocks) return; article.blocks.forEach(block => { if (block.type === "paragraph") { const mentions = block.data.text.match(/<a(?: .+?)?>@.*?<\/a>/g); if (mentions) { mentions.map(m => { const id = m.match(/href="\d"/)[0]; if (id) { mentionUserIds.push(Number(id.match(/\d/)[0])); } }); } } }); this.updateMentions(mentionUserIds); } }, mounted() { getUsers().then(res => { this.users = res.data; }); }, computed: { mentionableUsers() { return this.users.filter(u => !this.mentions.includes(u.id)); } }, methods: { setBlockObserver(editorId) { this.editorId = editorId; const editorDiv = document.getElementById(editorId); const elm = editorDiv.getElementsByClassName("codex-editor__redactor")[0]; const observer = new MutationObserver( function() { this.setParagraphNodeObserver(); }.bind(this) ); const config = { childList: true }; observer.observe(elm, config); this.setParagraphNodeObserver(); }, setParagraphNodeObserver() { const paragraphNodeObserverConfig = { characterData: true, attributes: false, childList: false, subtree: true }; const editorDiv = document.getElementById(this.editorId); const paragraphNodes = editorDiv.getElementsByClassName("ce-paragraph"); Array.from(paragraphNodes).forEach((pn, pi) => { const paragraphNodeObserver = new MutationObserver( function(mutations) { mutations.forEach(r => { const selection = document.getSelection(); const regex = RegExp("(\\s| )@", "g"); const currentCaretPosition = selection.focusOffset; const initialMention = r.target.data.indexOf("@") === 0 && currentCaretPosition === 1; const contextMentions = [...r.target.data.matchAll(regex)]; const isMention = initialMention || contextMentions.filter(cm => { return cm.index + cm[0].length === currentCaretPosition; }).length; if (isMention) { const rangeRect = selection .getRangeAt(0) .getBoundingClientRect(); const { x, y } = rangeRect; this.mentionMenu.selectedParagraphIndex = pi; this.mentionMenu.x = x; this.mentionMenu.y = y; this.mentionMenu.show = true; } else { this.mentionMenu.selectedParagraphIndex = null; this.mentionMenu.x = 0; this.mentionMenu.y = 0; this.mentionMenu.show = false; } }); }.bind(this) ); paragraphNodeObserver.observe(pn, paragraphNodeObserverConfig); }); }, insertMention(user) { const editorDiv = document.getElementById(this.editorId); const paragraphNodes = editorDiv.getElementsByClassName("ce-paragraph"); Array.from(paragraphNodes).forEach((pn, pi) => { if (this.mentionMenu.selectedParagraphIndex === pi) { const selection = document.getSelection(); const currentCaretPosition = selection.focusOffset; if (currentCaretPosition === 1) { let text = pn.textContent; text = text.slice(1, text.length); text = `<a href="${user.id}"class="text-decoration-none font-weight-bold">@${user.name}</a>` + text; pn.innerHTML = text; this.addMentionedUser(user.id); } else { const node = document.getSelection(); let targetText = node.anchorNode.data ? node.anchorNode.data : ""; const regex = RegExp("(\\s| )@", "g"); let contextMentions = [...targetText.matchAll(regex)]; const targetPoint = contextMentions.find(cm => { return cm.index + cm[0].length === node.anchorOffset; }); if (targetPoint) { let before = targetText.slice(0, targetPoint.index); let after = targetText.slice( targetPoint.index + targetPoint[0].length, targetText.length ); const mention = before + `<a href="${user.id}" data-user-id="${user.id}" class="text-decoration-none font-weight-bold">@${user.name}</a>` + after; if (pn.innerHTML.match(targetText)) { pn.innerHTML = pn.innerHTML.replace(targetText, mention); } else { const escapedText = this.escapeHtml(targetText); //$nbsp const replacedText = escapedText.replace( String.fromCharCode(160), " " ); if (pn.innerHTML.match(replacedText) !== null) { pn.innerHTML = pn.innerHTML.replace(replacedText, mention); } } } this.addMentionedUser(user.id); } } }); }, addMentionedUser(userId) { const mentions = [...this.mentions]; !this.mentions.find(id => id === userId) && mentions.push(userId); this.updateMentions(mentions); }, updateMentions(mentions) { this.$emit("updateMentions", mentions); }, updateArticle(article) { this.$emit("updateArticle", article); }, clear() { this.$refs.editor.clear(); } } }; </script> Editorjsでreadyとチェンジイベントを利用する。 初期化時にonReadyイベントを設定 holderのidを動的に設定しているのは、一画面で複数のエディターを利用するケースがあるため。 ArticleEditor.vue <script> //省略 this.editor = new EditorJS({ data: this.article, placeholder: "テキストを入力...", autofocus: true, readOnly: this.readOnly, minHeight: 0, onChange: function() { this.save(); //ブロックの追加や、テキストの編集などに応じて saveし、最新のオブジェクトを共有 }.bind(this), holder: this.uniqueId, tools: {}, i18n: {}, onReady: () => { this.$emit("ready", this.uniqueId); //準備完了の検知とholder divのid共有 } }); //省略 save() { this.editor .save() .then(data => { this.$emit("saved", data); }) .catch(err => { console.log(err); }); }, </script> MutationObserverの活用 Editorjsがいい感じに追加してくれるブロックに対して、それぞれMutationObserverをつけ、「@」の検知とメニューの表示をする <script> setBlockObserver(editorId) { this.editorId = editorId; const editorDiv = document.getElementById(editorId); //holder div const elm = editorDiv.getElementsByClassName("codex-editor__redactor")[0]; //ブロックが追加されていくdiv const observer = new MutationObserver( function() { this.setParagraphNodeObserver(); //ブロックが追加されたら子ノードに対してMutation Observer付けるというコールバック }.bind(this) ); //要素の追加削除の変更のみ検知する設定 const config = { childList: true }; observer.observe(elm, config); //監視開始 this.setParagraphNodeObserver(); //Editorjsが初期化時に追加するParagraphブロック対応用に手動で読んでいる }, </script> 追加されたDomがParagraphブロックなのか調べ、「@」を検知するObserverを設置 <script> setParagraphNodeObserver() { const paragraphNodeObserverConfig = { characterData: true, //textContentの変更を見る attributes: false, //属性の変更は見ない childList: false, //子要素の追加削除は見ない subtree: true, //サブツリーまで見る }; const editorDiv = document.getElementById(this.editorId);//holder const paragraphNodes = editorDiv.getElementsByClassName("ce-paragraph"); //paragraph dom Array.from(paragraphNodes).forEach((pn, pi) => { const paragraphNodeObserver = new MutationObserver( function(mutations) { mutations.forEach(r => { const selection = document.getSelection(); //caretの取得 const regex = RegExp("(\\s| )@", "g"); //半角・全角スペースの後ろに@があるか const currentCaretPosition = selection.focusOffset; //caretの位置(textContent上での位置になるため、innerHtmlの位置ではない)取得 const initialMention = r.target.data.indexOf("@") === 0 && currentCaretPosition === 1; //行の先頭にあるかのチェック const contextMentions = [...r.target.data.matchAll(regex)]; //文中に正規表現にマッチする@があるか const isMention = initialMention || contextMentions.filter(cm => { return cm.index + cm[0].length === currentCaretPosition; //caretのある位置の@がメンションであるかのチェック }).length; if (isMention) { const rangeRect = selection .getRangeAt(0) .getBoundingClientRect(); //caretの画面上の座標取得 const { x, y } = rangeRect; //メンションメニューの表示 this.mentionMenu.selectedParagraphIndex = pi; //何番目のパラグラフなのか一時保存 this.mentionMenu.x = x; this.mentionMenu.y = y; this.mentionMenu.show = true; } else { this.mentionMenu.selectedParagraphIndex = null; this.mentionMenu.x = 0; this.mentionMenu.y = 0; this.mentionMenu.show = false; } }); }.bind(this) ); paragraphNodeObserver.observe(pn, paragraphNodeObserverConfig); //監視コールバックとオプションの定義 }); }, </script> メンションメニューからメンションを追加する メンションメニューに関してはVuetifyにほぼお任せ、座標と表示可否を管理するだけ。対象のユーザーがクリックされた時にクリックイベントからメンション追加メソッドを呼ぶ。 <script> insertMention(user) { const editorDiv = document.getElementById(this.editorId); //holder div const paragraphNodes = editorDiv.getElementsByClassName("ce-paragraph"); //paragraph div Array.from(paragraphNodes).forEach((pn, pi) => { //indexで対象パラグラフ特定 if (this.mentionMenu.selectedParagraphIndex === pi) { const selection = document.getSelection(); //caretの取得 const currentCaretPosition = selection.focusOffset; //caret位置の取得 if (currentCaretPosition === 1) { let text = pn.textContent; //textContent取得 text = text.slice(1, text.length); text = `<a href="${user.id}"class="text-decoration-none font-weight-bold">@${user.name}</a>` + text; pn.innerHTML = text; //メンションタグ追加 this.addMentionedUser(user.id); //サーバーサイドに送るように別途保存 } else { const node = document.getSelection(); let targetText = node.anchorNode.data ? node.anchorNode.data : ""; //caretのある位置のtextContentを取得(対象パラグラフの中に他にもアンカータグやイタリックなどが含まれている場合には、キャレットのある位置からタグが見つかる位置までをanchorNodeとして取得してくれる) const regex = RegExp("(\\s| )@", "g"); let contextMentions = [...targetText.matchAll(regex)]; const targetPoint = contextMentions.find(cm => { return cm.index + cm[0].length === node.anchorOffset; }); if (targetPoint) { let before = targetText.slice(0, targetPoint.index); let after = targetText.slice( targetPoint.index + targetPoint[0].length, targetText.length ); const mention = before + `<a href="${user.id}" data-user-id="${user.id}" class="text-decoration-none font-weight-bold">@${user.name}</a>` + after; if (pn.innerHTML.match(targetText)) { pn.innerHTML = pn.innerHTML.replace(targetText, mention); } else { //特殊文字やtextContentとinnerHtmlで差異が出る特殊文字などを変換した上で、置換する const escapedText = this.escapeHtml(targetText); //$nbsp const replacedText = escapedText.replace( String.fromCharCode(160), " " ); if (pn.innerHTML.match(replacedText) !== null) { pn.innerHTML = pn.innerHTML.replace(replacedText, mention); } } } this.addMentionedUser(user.id); } } }); }, </script>
- 投稿日:2021-08-29T11:30:55+09:00
Vue3 + Typescript + bootstrap5をdocker-composeで始める [環境セットアップ編]
手順 Dockerfile/docker-compose.yamlを用意 vue cliでプロジェクトの雛形を作成 bootstrap5のインストールと適用 ディレクトリ構成 - project_root - docker-compose.yaml - Dockerfile - frontend <- Vueプロジェクト - node_modules - public - src - etc... Dockerfile/docker-compose.yamlを用意 vue用コンテナのDockerfileを作成 nodeは投稿時点でLTSの14を使用し、vue cliをインストール Dockerfile FROM node:14 WORKDIR /frontend RUN apt update -y && \ apt upgrade -y && \ npm install -g @vue/cli @vue/cli-service-global docker-compose.yamlを作成 docker-compose.yaml version: '3' services: frontend: build: . ports: - 8080:8080 volumes: - ./frontend:/frontend command: npm run serve vue cliでプロジェクトの雛形を作成 コンテナに接続 $ docker-compose build $ docker-compose run frontend bash vue cliでプロジェクトの雛形を作成 ローカルのfrontend直下にプロジェクトを作成したいのでディレクトリを1階層上にしています $ cd ../ $ vue create frontend すでにディレクトリがある場合は上書きするかマージするか選択します。 マージを選択します。 ? Target directory /frontend already exists. Pick an action: (Use arrow keys) Overwrite ❯ Merge Cancel オプションを選択していく Vue CLI v4.5.13 ? Please pick a preset: Default ([Vue 2] babel, eslint) Default (Vue 3) ([Vue 3] babel, eslint) ❯ Manually select features Manually select featuresを選択 ? Check the features needed for your project: ◉ Choose Vue version ◉ Babel ◉ TypeScript ◯ Progressive Web App (PWA) Support ◉ Router ◉ Vuex ❯◉ CSS Pre-processors ◉ Linter / Formatter ◯ Unit Testing ◯ E2E Testing Typescript, Router, Vuex, CSS Pre-processorsにチェックをつける ? Choose a version of Vue.js that you want to start the project with (Use arrow keys) 2.x ❯ 3.x Vueのバージョンは3.xを選択 ? Use class-style component syntax? (y/N) N 生成されるvueファイル(Home.vueなど)をclass-styleかobject-styleどちらにするかという問 object-styleはdefineComponentを使ったVue3のスタンダード的な書き方なので、こちらではNを選択 Yを選択した場合でも自分でobject-styleに書き直すことは出来ます ? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? (Y/n) Y babel.config.jsを自動生成するか否か。 今回は生成して欲しいのでYを選択 Use history mode for router? (Requires proper server setup for index fallback in production) (Y/n) Y vue routerをhistory modeで使用するかの確認 vue routerにはhistory modeとhash modeの2つがあるのですが、それらの違いについては下記の記事が参考になります。 https://qiita.com/kozzzz/items/af9ad63fa70d4724cc2a 今回はhistory modeを使用するのでYを選択します ? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): ❯ Sass/SCSS (with dart-sass) Sass/SCSS (with node-sass) Less Stylus CSS pre-processorに何を使用するかの確認 Sass/SCSSの場合はdartが推奨されているのでそちらを選択します。 参考: https://sass-lang.com/blog/libsass-is-deprecated ? Pick a linter / formatter config: ESLint with error prevention only ESLint + Airbnb config ESLint + Standard config ❯ ESLint + Prettier TSLint (deprecated) linter/formatterの選択 今回はESLint + Prettierを使用します linter/formatterについては下記の記事が参考になりました。 https://reffect.co.jp/vue/eslint ? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection) ❯◉ Lint on save ◯ Lint and fix on commit ESLintの実行タイミングを選択します 保存時に実行するLint on saveを選択します。 選択肢はどちらも選択することが出来ます ? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys) ❯ In dedicated config files In package.json babelやeslint等の設定をどのファイルに持つかという問 専用のファイル、package.jsonかどちらかを選択します 今回はIn dedicated config filesを選択 ? Save this as a preset for future projects? (y/N) N 上記の設定内容をプリセットとして保存するかの問です。 今回は不要なのでNを選択します ? Pick the package manager to use when installing dependencies: (Use arrow keys) Use Yarn ❯ Use NPM パッケージマネージャーにどちらも使用するかの問 今回はnpmを選択します この問を選択するとvueプロジェクトが作成されます。 vue.config.jsの作成 このままだとホットリロードが効かないので/frontend/vue.config.jsを作成して設定します vue.config.js module.exports = { configureWebpack: { devServer: { watchOptions: { poll: true } } } } bootstrap5のインストールと適用 bootstrap5のインストール bootstrap-iconsが不要であれば削除してください $ cd frontend $ npm install bootstrap@next @popperjs/core bootstrap-icons 適用 frontend/src/main.tsでbootstrapをimport bootstrap-iconsが不要であれば削除してください main.ts import { createApp } from "vue"; import App from "./App.vue"; import router from "./router"; import store from "./store"; import 'bootstrap' import 'bootstrap/dist/css/bootstrap.css'; import 'bootstrap-icons/font/bootstrap-icons.css' 動作確認 Home.vue等を修正してbootstrap5とbootstrap-iconsが導入されているか確認用のコードを追記します Home.vue <template> <div class="home"> <!-- Bootstrap 確認用 --> <button type="button" class="btn btn-primary">Primary</button> <!-- Bootstrap-icons 確認用 --> <i class="bi bi-file-bar-graph"></i> <img alt="Vue logo" src="../assets/logo.png" /> <HelloWorld msg="Welcome to Your Vue.js + TypeScript App" /> </div> </template> <script lang="ts"> import { defineComponent } from "vue"; import HelloWorld from "@/components/HelloWorld.vue"; // @ is an alias to /src export default defineComponent({ name: "Home", components: { HelloWorld, }, }); </script> 起動 コンテナから抜けて、起動します $ exit $ docker-comopose up -d localhost:8080をブラウザで叩き無事表示出来たら終了です
- 投稿日:2021-08-29T09:02:46+09:00
vue-cliではじめるWebアプリ制作
0. はじめに この資料はjavascriptの基本的な書き方を学習し終わった方向けに vue-cli3を使った簡易なWebアプリの作り方を説明するための資料となっています。 内容としては vue-cli3の使い方 作成されるプロジェクトの概要 Vue.jsの基本的な機能 APIサーバーの作成方法 UIとAPIサーバーの通信方法 を含み、またおまけとして ibmcloudのcloudfoundryを用いたwebへのデプロイ方法 Watson Assistantを用いたチャットボットアプリへの拡張 を含みます。 抜け、漏れや間違いなどございましたらコメントでご指摘いただけますと幸いです。 1. 環境構築 実施環境 今回のプロジェクトは以下のような環境で作成しました。 mac : Big Sur 11.5 google chrome : 92.0 npm : 7.18.1 node : v16.4.1 @vue/cli : 4.5.13 ibmcloud cli : 1.5.1 node.jsの導入 この辺りを参考にnode.jsを導入してください。 MacにNode.jsをインストール node.js公式サイト vue-cli3の導入 ターミナル(windowsならPowershell)を開いて次のコマンドを実行してください。 npm install -g @vue/cli 完了したら次のコマンドを実行してインストールが正しく行えたか確認してください。 vue --version 次のようにバージョンが表示されたらインストール成功です。 @vue/cli 4.5.13 環境構築はここまで 2. アプリの作成 アプリを置いておきたいディレクトリに移動して次のコマンドを実行してください (フォルダごと作成されるので専用のフォルダなどを作る必要はありません) vue create <アプリ名> 以下は作成時に聞かれる項目の解説です ? Please pick a preset: (Use arrow keys) ❯ Default ([Vue 2] babel, eslint) Default (Vue 3) ([Vue 3] babel, eslint) Manually select features オプションのプリセット、簡単なアプリを作るだけならDefaultでも事足りますが、 今回はよく使う機能の説明も含むので一番下のManually select featuresを選択してださい。 ? Check the features needed for your project: (Press <space> to select, <a> to t oggle all, <i> to invert selection) ❯◉ Choose Vue version ◉ Babel ◯ TypeScript ◯ Progressive Web App (PWA) Support ◉ Router ◯ Vuex ◯ CSS Pre-processors ◯ Linter / Formatter ◯ Unit Testing ◯ E2E Testing 最初から導入しておきたいパッケージを選択します ↑↓キーで選択、Spaceキーで切り替え、Enterキーで完了 今回はVue3.0, Babel, Routerのみを有効にしてください 各パッケージの簡単な説明 Choose Vue version Vueのバージョンを選択する Babel javascriptの新しい記法を色々なブラウザで使えるようにするパッケージ 参考:【5分でなんとなく理解!】Babel入門 TypeScript javascriptを拡張した言語 型が明確、継承がサポートされているなど複数人が関わるプロジェクトで役立つ機能が多い Progressive Web App (PWA) Support Webアプリからスマートフォンの一部機能を利用できるようになる機能 Router ページを遷移せずに複数のコンテンツを表示するために利用する SPA(SinglePageApplication、単一のページでコンテンツを表示するwebアプリケーション)の実装に重要 Vuex 異なるページの間でデータを共有するために利用する CSS Pre-processors SASSなどのCSSを拡張するパッケージ Linter / Formatter インデントや関数の形式などコードの書き方を管理するパッケージを導入する 複数人が関わるプロジェクトではほぼ必須 Unit Testing コードの単体テストを行うパッケージを導入する DevOpsやTDD(テスト駆動開発)で利用されるが、テストコード自体も適切な管理が必要なので注意 E2E Testing コードのEndToEndテスト(結合テスト)を実施するパッケージを導入する 注意点はUnitTestと同様 ? Choose a version of Vue.js that you want to start the project with (Use arrow keys) 2.x ❯ 3.x Vueのバージョンを選択します。 ネットにある記事は2.0のものが多いですが、 3.0では周辺モジュール(vue-routerなど)の書き方が変わっていたりUIコンポーネント(Bootstrapなど)が一部対応していなかったりするので注意してください。 今回は3.xを選択してください。 ? Use history mode for router? (Requires proper server setup for index fallback in production) (Y/n) Routerを導入した場合のみ発生、vue-routerをhistoryモードで動かすかどうかについての質問です。 通常Vue-Routerを使用すると各コンテンツのURLは http://localhost:8080/#/hoge のように # が必要となるが、historyモードを用いると # を省略して http://localhost:8080/hoge と記述できます ただし、こちらのモードを利用する場合以下のような仕様があるため注意してください。 サーバへの通信が発生するため速度が低下する アプリをデプロイする際サーバー側で設定が必要 History APIをサポートしていないブラウザで動作しない 詳細な説明はこちら 今回はhistoryモードを使用しないため、 n を入力してください。 ? Where do you prefer placing config for Babel, ESLint, etc.? ❯ In dedicated config files In package.json Babel, ESLintなど各種パッケージの設定をどこで行うかを設定する質問です。 In dedicated config filesでは個別のconfig fileを作成し、 In package.jsonではpackage.jsonでまとめて管理します 今回は In dedicated config files を選択してください。 ? Save this as a preset for future projects? (y/N) 今回の設定をプリセットとして保存するかどうかの質問です。 今回は保存しないため n を入力しください。 ? Pick the package manager to use when installing dependencies: (Use arrow keys) ❯ Use Yarn Use NPM package管理ツールの選択です。 今回はyarnを使用するので Use Yarn を選択してください。 3. 作成されたプロジェクトの確認 3.1. プロジェクト構造の確認 次のような構造のプロジェクトが作成されます。 各ファイルの説明 babel.config.js babelの設定ファイル node_modules npm or yarnでインストールしたパッケージが配置されるフォルダ package.json パッケージの状況やアプリの実行コマンドなどアプリのコンフィグの大部分を管理するファイル public クラウドなどにデプロイするためのビルド済みファイルが配置されるフォルダ README.md アプリの説明文を記入するファイルgitなどのリポジトリで自動表示される src 画面ファイルのソースコードが入っているフォルダ App.vue Webアプリのメイン画面を記述するソースコード 全てのコンテンツに共通する内容はここで記述する assets 動的な変更を必要としない画像や動画などを配置するフォルダ components 画面部品を配置するフォルダ 複数の箇所で使い回す場合や責務の分離が必要な場合、コンポーネントとして分離する main.js Webアプリ起動時に最初に動作するファイル App.vueなどを呼び出す router vue-routerの設定ファイルが入っているフォルダ views 各コンテンツの内容はここで記述する webページ作成で主に編集するフォルダ yarn.lock yarnにおけるパッケージの依存関係を管理するファイル package.jsonを変更すると自動で変更される 起動して確認するためには以下のコマンドを実行する yarn serve 次のような表示が出たら実行準備完了です。 App running at: - Local: http://localhost:8080/ - Network: http://192.168.50.136:8080/ Note that the development build is not optimized. To create a production build, run yarn build. ブラウザを開いてhttp://localhost:8080/にアクセスしてください。 このような画面が表示されれば成功です。 アプリを終了するときには control + C キーを同時押ししてください。 3.2. コードを編集してみる src/views/Home.vueをメモ帳,vim,vscodeなどのエディタで開いた後 再度 yarn serveでアプリを起動してください。 `src/views/Home.vue` <template> <div class="home"> <img alt="Vue logo" src="../assets/logo.png"> <HelloWorld msg="Welcome to Your Vue.js App"/> // ← ここを編集 </div> </template> <script> // @ is an alias to /src import HelloWorld from '@/components/HelloWorld.vue' export default { name: 'Home', components: { HelloWorld } } </script> Welcome to Your Vue.js Appを適当な文字列に書き換えて保存してみましょう 指定した文字列に書き換わりましたね このようにyarn serveでの起動中にソースコードを書き換えるとアプリケーションに自動で反映されます 3.3. routerの確認 アプリを動かしたままもう一つターミナルを開いて /src/router/index.jsを開いてみましょう `/src/router/index.js` import { createRouter, createWebHashHistory } from 'vue-router' import Home from '../views/Home.vue' const routes = [ { path: '/', name: 'Home', component: Home }, { path: '/about', name: 'About', // route level code-splitting // this generates a separate chunk (about.[hash].js) for this route // which is lazy-loaded when the route is visited. component: () => import(/* webpackChunkName: "about" */ '../views/About.vue') } ] const router = createRouter({ history: createWebHashHistory(), routes }) export default router ここで画面に表示するコンテンツの元になるファイルを指定しています 変数routesに注目してみましょう import Home from '../views/Home.vue' const routes = [ { path: '/', name: 'Home', component: Home }, { path: '/about', name: 'About', component: () => import('../views/About.vue') } ] ここで各コンテンツのURLと表示する内容を設定しています それぞれ path: URLにおける #以降のアドレス name: コンテンツの名前 component: 表示する元となるファイル を表します、例えば2個目のaboutならば http://localhost:8080/#/aboutにアクセスすると/src/views/About.vueファイルを表示することになるわけです 実際にhttp://localhost:8080/#/aboutにアクセスしてみます。 /src/views/About.vue <template> <div class="about"> <h1>This is an about page</h1> </div> </template> /src/views/About.vueと見比べてみると一致しているのがわかると思います。 (上のHome|Aboutは/src/app.vueに書かれた共通処理) 4. Vue.jsの記法 4.1. 準備 この項ではVue.jsでよく使う記法について書いていきます まずテスト用に新しいページを作ります。 /src/router/index.jsの変数routesに { path: '/test', name: 'Test', component: () => import('../views/Test.vue') } を追加しましょう。 const routes = [ { path: '/', name: 'Home', component: Home }, { path: '/about', name: 'About', component: () => import('../views/About.vue') }, { path: '/test', name: 'Test', component: () => import('../views/Test.vue') } //これを追加 ] この時ブラウザはエラーになりますが一旦放置で大丈夫です。 続いて/src/viewsフォルダにTest.vueという名前のファイルを作りましょう。 この時点でブラウザのエラーが解消されるのでhttp://localhost:8080/#/testにアクセスします。Home|Aboutだけが表示されている状態になったら準備完了です まずTest.vueに次のように書き込みます Test.vue <template> </template> <script> export default { name: 'Test', components: {}, methods: { }, data: () => ({ }), } </script> <style> </style> これがvue.jsでファイルを扱う際の基本形です <template> ~ </template> の範囲がWebページの基本的な構造、仕組みを書く部分 <script> ~ </script> の範囲が変数や関数などの処理を書く部分 <style> ~ </style> の範囲が見た目の飾り付けを書く部分 となります <style>ブロックは通常のCSSと同じ挙動でクラスなどを用いて見え方を調整することができますが、今回の内容では扱いません。 まず <template> ~ </template>の中に <div> Hello Vue </div> と書き込んで保存してみます。 このように書き込んだ文字が表示されたら正しくRoutingできています 4.2. 基本機能の確認 vue.jsの基本的な機能をいくつかテストしていきます。 4.2.1.変数の表示 <script> ~ </script>のdata: () => ({ ~~~~ })の中に変数を定義します <script> export default { name: 'Test', components: {}, methods: { }, data: () => ({ testData: 'Vueはじめました' //このように定義 }), } </script> 続いて先ほど作った<div>ブロックの中に{{今定義した変数}}の形で書き込んで保存します。 <template> <div> {{testData}} </div> </template> 画面には先ほど定義した変数の内容が表示されているはずです。 このようにdataで定義した変数を{{ }}で囲うことでその値を表示することができます。 4.2.2. タグ属性の操作(v-bind) htmlタグの属性情報を変数に紐付けて自由に変更できるようにしてみます。 まず、先ほど作った<div>ブロックの中に <img src="../assets/logo.png" width='100'> を記入して保存します。 (この画像は/src/assetsの中に入っています) <template> <div> <img src="../assets/logo.png" width='100'> </div> </template> このように画像が表示されたと思います。 この時、src="../assets/logo.png" width='100'の部分をタグの属性情報と言い、これを変更することで表示する画像や画像のサイズを操作することができます。 試しにwidthを200などに書き換えてみると画像のサイズが倍になることが確認できると思います。 jqueryや素のjavascriptなどを使う場合にはWebページが開かれているときに属性情報を変更するためにはタグを検索して取得、変更するような手順が必要となります。 一方vueでは、属性情報と変数を紐付けておき、変数が変更されたときに属性情報を書き換えることが可能です。 実際にテストしてみます。 まず、4.2.1.と同様変数部分にimgWidth: 100を定義します、 その後、<img src="../assets/logo.png" width='100'>のwidth='100'をv-bind:width='imgWidth'に書き換えてみましょう。 Test.vue <template> <div> <img src="../assets/logo.png" v-bind:width='imgWidth'> //属性情報を書き換え </div> </template> <script> export default { name: 'Test', components: {}, methods: { }, data: () => ({ imgWidth: 100 //imgWidthを定義 }), } </script> この状態でimgWidthの値をさまざまに切り替えて保存してみましょう。 属性情報を直接書き換えた時のように画像のサイズが切り替わっているのがわかると思います。 このようにv-bind:<属性情報>='<変数名>'という記法を使うことで タグの属性情報に変数を紐付けできるわけです。 また、v-bindは省略可能で一般的には:<属性情報>='<変数名>'と書くことが多いのでこちらも覚えておくと良いでしょう。 属性情報の紐付けはアプリの見た目を考える段階、特にBootstrapなどのUIコンポーネントを使うようになると使う機会が大きく増えます。 4.2.3.関数の実行(v-on) 関数を定義することでボタンのクリックなど様々なイベントに対して処理を割り当てることができるようになります。 まず実行用のボタンを作ります。 4.2.2.で挿入した<img>タグの下に<input type='button' value='test'>を追加しましょう。 <template> <div> <img src="../assets/logo.png" v-bind:width='imgWidth'> <input type='button' value='test'> </div> </template> 図のように画像の下にボタンが表示されたはずです。 今はこのボタンを押しても何も起こりません。これからこのボタンに処理を追加していきます。 続いて、<script>ブロックのmethods:{}の中に関数を定義します。 関数は 書き方1 関数名(){ 〜処理〜 } 書き方2 関数名: function(){ 〜処理〜 } のような形で定義することができます。 どちらを選んでも良いですが、コードの中で複数の書き方を混在させないようにしましょう。 今回はボタンを押したときに画像のサイズを変更する処理を実装したいと思います。 changeImgSize(){ this.imgWidth = 200 } このとき、dataにある変数はthis.変数の形で記入してください (関数の中で他の関数を呼び出す場合も同様にthis.関数の形で書きます) では、定義した関数をボタンで呼び出せるようにします。 <input type='button' value='test'>に v-on:click='changeImgSize'を追加します。 <template> <div> <img src="../assets/logo.png" :width='imgWidth'> <input type='button' value='test' v-on:click='changeImgSize'> </div> </template> 保存してtestボタンをクリックしてみましょう。 図のように画像サイズが大きくなったはずです。 このようにv-on:<イベント>='<関数>'という記法を使うことでクリックやテキスト入力、マウスオンなどのイベントに対して関数を紐づけることができるようになりました。 こちらのv-onも省略記法があり@<イベント>='<関数>'の形で書くことができます。 また、今回関数を'changeImgSize'と( )のない形で書きましたが、'changeImgSize(変数)'のように書くことで通常の変数ありの関数も扱うことができます。 イベントの種類はこの辺りを参考にしてください。 ここで、関数を扱う上で重要なデバッグ機能について追記しておきます。 javascriptで変数の途中の状態や関数が実行されているかどうかを確認する方法の一つとしてconsole.logがあります。これを関数内で実行することで、指定した変数や文字列をコンソールに表示することが可能です。 例) changeImgSize(){ this.imgWidth = 200 console.log(this.imgWidth) //imgWidthの値を表示 } この時、console.logの結果が表示されるのはyarn serveが動いているターミナルではなくブラウザのデバッグコンソールです。 これはブラウザを開いた状態で mac:command + option + i windows:Ctrl + Shift + i を押すと開くもので、コンソールの確認の他にもスマホなどでの見え方の確認やCSSの調整など様々な機能がありますのでwebアプリを作る時には必ず確認するようにしましょう。 4.2.4. 繰り返し表示(v-for) チャットや一覧表示のようにコンテンツを繰り返し表示する必要がある場合、 vueではv-forという記法を用いて書くことができます。 まず、data内にtestArray: ['テキスト1','テキスト2','テキスト3']という配列を定義します。 続いてブロック内に <div v-for="data in testArray"> {{data}} </div> を追加します。 Test.vue <template> <div v-for="data in testArray"> {{data}} </div> </template> <script> export default { name: 'Test', components: {}, methods: {}, data: () => ({ testArray: [ 'テキスト1', 'テキスト2', 'テキスト3' ] }), } </script> 図のように配列の内容が表示されたら成功です。 v-forは指定したブロックを配列内の要素分繰り返す記法です、 確認のため作成した<div>ブロックの中に <img src="../assets/logo.png" width='50'> を記入してみます。 <template> <div v-for="data in testArray"> {{data}} <img src="../assets/logo.png" width='50'> </div> </template> このようにv-forで指定したブロックの中身が繰り返されていることがわかります。 また、次のように配列内にオブジェクトを指定するとv-forブロック内で表示する内容を変更することができます。 testArray: [ { text: 'タイトル1', width: '50' }, { text: 'タイトル2', width: '100' }, { text: 'タイトル3', width: '200' }, ] 例として以下のように書くと <template> <div v-for="data in testArray"> {{data.text}} <img src="../assets/logo.png" :width='data.width'> </div> </template> testArrayから取り出されたObjectがdataに代入され、data.text、data.widthの形で値を取り出すことができます。 このサンプルの結果は図のようになります。 このようにv-for='<取り出し先> in <配列>'という記法を使うことでhtml要素を配列の要素分繰り返すことができるようになりました。 4.2.5. 変数の同期(v-model) 4.2.2.のv-bindで属性情報に変数を紐付けました。 v-bindは変数が変わった際にhtml要素を操作することができますが、html要素を変更した場合に変数を変更することはできません。 例として以下のような<input>タグの挙動を試します <template> <div> {{testData}} </div> <div> <input type='text' :value='testData'> </div> </template> <script> export default { name: 'Test', components: {}, methods: {}, data: () => ({ testData: 'テスト' }), } </script> この時、上の表示欄と下の入力欄の両方にテストが表示されていますが、 入力欄を書き換えた場合、 書き換え欄と入力欄が一致しません。 このようにhtmlへの入力を変数に反映させるためにはv-modelという記法を使います。 <input>ブロックの:valueをv-modelに書き換えて保存し、ブラウザを更新してください。 入力欄を書き換えると表示欄の内容が同時に書き変わるようになるはずです。 このように、v-model='<変数>'という記法を使うことでhtml要素と変数を同期させることができるようになります。 なお、入力を同期させたい場合には@change='変数に入力を代入する関数'のような方法で代用することも可能です。v-modelとchangeイベントは同時に使えないため、入力時に複数の処理を行いたい場合にはこちらを利用するのが良いと思います。 4.2.6. 要素の使い回し(コンポーネント) vueではvueファイルの中で他のvueファイルを呼び出してコンポーネント(部品)として扱うことができます。 これによって色々なページで使い回す要素(ヘッダーやフッターなど)やv-for内で繰り返す要素を分割して管理することができます。 これを試すために /src/componentsにTestComponent.vueというファイルを作って以下のように書き込んでください。 /src/components/TestComponent.vue <template> <div> コンポーネント </div> </template> <script> export default { name: 'TestComponent', props: { }, methods: {}, data: () => ({ }), } </script> 続いて、Test.vueにおける<script>ブロックにimport TestComponent from '@/components/TestComponent.vue'を書き込んでください。 また、methodsの上にcomponents: { TestComponent },を作成します。 完成形はこのようになります。 Test.vue <script> import TestComponent from '@/components/TestComponent.vue' export default { name: 'Test', components: { TestComponent }, methods: {}, data: () => ({ }), } </script> これで先ほどTestComponent.vueで定義したコンポーネントを利用できるようになりました。 続いて<template>ブロックの中に読み込んだ<TestComponent>でブロックを作ります。 Test.vue <template> <div> <TestComponent> </TestComponent> </div> </template> コンポーネントの内容がTest.vueのページに反映されました。 コンポーネントは通常のhtml要素同様に属性情報を渡すことが可能です。 TestComponent.vueのpropsオブジェクトの中にmsg: Stringを追加してみましょう この時、Stringは入力をString型で行うことを表します。 また、<template>ブロックにmsgを表示する記述を追加します。 完成形はこのようになります。 /src/components/TestComponent.vue <template> <div> コンポーネント:{{msg}} </div> </template> <script> export default { name: 'TestComponent', props: { msg: String }, methods: {}, data: () => ({ }), } </script> 続けて、Test.vue の<TestComponent>ブロックにmsg='親コンポーネントからの入力'という属性情報を追加しましょう。 Test.vue <template> <div> <TestComponent msg='親コンポーネントからの入力'> </TestComponent> </div> </template> 図のようにTest.vueで入力した内容がTestComponent.vueに反映されて表示されます。 このように、Componentを使うことで複数回使う要素を一回で記述したり、ページを部品ごとに分担して作成することができるようになります。 これはvueに限らずAngularなど他のフレームワークでも多用されるテクニックなので覚えておくと良いと思います。 5.APIサーバーの作成 ここからはUI側の表示ではなく、ファイル操作や認証を伴うAPIの呼び出し、DB操作などサーバー側で行う処理を作っていきます。 まず、yarn serveが動いている場合にはCtrl + Cで停止してください。 続けて作成したプロジェクトのpackage.jsonが入っているディレクトリでvue add expressを実行してください。 (プロジェクト内であればどこでも実行できますが説明を簡単にするためにこのようにします。) このような表示が出てきた場合にはyを入力してください WARN There are uncommitted changes in the current repository, it's recommended to commit or stash them first. ? Still proceed? (y/N) 続いてこのような表記が出るのでyを入力してください ? Should serve vue app? (Y/n) 最後にこのような表記が出るので、サーバー機能を保存したいフォルダ名を入力してください。 (今回はそのまま実行しsrvディレクトリに保存) ? Where will be located your server? (./srv) ディレクトリを確認すると今入力した名前のフォルダができているはずなので、中に入っているindex.jsを開きましょう。 /srv/index.js import express from 'express'; // import socketIO from "socket.io"; export default (app, http) => { // app.use(express.json()); // // app.get('/foo', (req, res) => { // res.json({msg: 'foo'}); // }); // // app.post('/bar', (req, res) => { // res.json(req.body); // }); // // optional support for socket.io // // let io = socketIO(http); // io.on("connection", client => { // client.on("message", function(data) { // // do something // }); // client.emit("message", "Welcome"); // }); } 中にはサンプルが入っていますが、socket.ioは今回使わないため削除し、そのほかのコメントアウトされた箇所を戻します。 (socket.ioはリアルタイムの情報通信を可能にする接続形式です。 チャットなどを作る場合には非常に便利なので気になる方は調べてみてください) /srv/index.js import express from 'express'; export default (app, http) => { app.use(express.json()); app.get('/foo', (req, res) => { res.json({msg: 'foo'}); }); app.post('/bar', (req, res) => { res.json(req.body); }); } これでサーバー側機能の基本形は完成です。 yarn expressでサーバーを起動し、http://localhost:3000/fooにアクセスしてみましょう 図のように表示されたと思います。 /srv/index.jsで定義された app.get('/foo', (req, res) => { res.json({msg: 'foo'}); }); と比較してみると一致しているのがわかると思います。 こちらもUI同様、起動中に書き換えた内容は即座に反映されるため、 内容を書き換えて画面を更新すると書き換えた内容が表示されるようになります。 このように作成したAPIをテストする場合には主にブラウザの拡張機能が利用できます。 例) Chlome:Talend API Tester Firefox:RESTClient Talend Api Testerの動作例 SCHEMAにURLを設定し、BODYに送る内容を記入してSendボタンを押すとテストできます。 結果はこのようになります。 6.UIとServerの通信 最後に、UIからServer側で作成したAPIを呼び出せるようにします。 このような通信を行うパッケージはいくつかありますが、今回はaxiosというパッケージを使います。 まず、ServerとUIの両方をCtrl + Cで停止させた上で yarn add axios vue-axios を実行してください。 続けて、/src/main.jsを開き、下のように書き換えてください。 /src/main.js import { createApp } from 'vue' import App from './App.vue' import router from './router' import axios from 'axios' //追加 import VueAxios from 'vue-axios' //追加 createApp(App) .use(router) .use(VueAxios, axios) //追加 .mount('#app') 完了したら保存して閉じ、次にvue.config.jsを開いてください。 こちらにはdevServer: { proxy: 'http://localhost:3000' }を追加して保存します。 /vue.config.js module.exports = { pluginOptions: { express: { shouldServeApp: true, serverDir: './srv' } }, devServer: { proxy: 'http://localhost:3000', } //追加 } この処理は開発環境の時(yarn serveの時)このserverから外にアクセスした際に自動で API Serverが動いているポートにアクセスできるようにするための設定です。 詳細は省きますが、このように設定しないとAPIへのアクセスができません。 興味のある方はCORSで調べてみてください。 以上が完了したら/src/Test.vueをエディタで表示した上で、 yarn serve 及び yarn express を二つのターミナルを使って同時に実行してください。 vue-axiosを導入するとAPIの呼び出しを下のような形で行うことができます。 this.axios.get('/<apiのURL>') .then((res) => { 処理 }).catch((err) => { エラー時の処理 }) この時、APIの呼び出しは非同期で行われます。 つまり、 let test = '呼び出し前の値' this.axios.get('/<apiのURL>') .then((res) => { test = '呼び出し後の値' }) console.log(test) のように書いた場合console.logで表示されるのは'呼び出し前の値'になります。 もし、APIを呼び出した後の処理を使って処理を続けたい場合は this.axios.get('/<apiのURL>') .then((res) => { 処理1 }).then((res) => { 処理2 }).then((res) => { 処理3 }).catch((err) => { エラー時の処理 }) のように書く必要があります。 このようにthen,catchを使って同期処理・エラーハンドリングを行う書き方はPromiseという機能に従ったもので処理を正しい順序で行うために非常に重要な機能なので、拡張形であるasync,awaitと合わせて覚えておいてください。 また、then()の中で利用されている () => { 処理 } のような記法をラムダ式と言い、名前を持たない簡易の関数を定義するための記法です。 (入力) => { 処理 return 返り値 } あるいは (入力) => (返り値) の形で書くことができ、Promiseだけでなくfilterやmapといった プロトタイプ関数を使う際にもよく利用する記法ですので覚えておいてください。 さて、これらを用いてTest.vueをAPIとの通信をテストできるよう書き換えます。 /views/Test.vue <template> <div> <input type='text' v-model='sendData'> </div> <div> <input type='button' @click='getMessage' value='受信'> <input type='button' @click='sendMessage' value='送信'> </div> <div>送るテキスト:{{sendData}}</div> <div>受け取ったテキスト:{{getData}}</div> </template> <script> export default { name: 'Test', methods: { getMessage(){ this.axios.get('/foo') .then((res) => { this.getData = res.data.msg }) }, sendMessage(){ this.axios.post('/bar', { msg: this.sendData }) .then((res) => { this.getData = res.data.msg }) } }, data: () => ({ sendData: '送信するテキスト', getData: '受け取ったテキスト' }), } </script> 画面は図のようになります。 getMessage関数ではaxiosのget methodを使い、/fooの内容を受け取ってgetData変数に代入しています。 sendMessage関数ではaxiosのpost methodを使い、/barに対してsendData変数を送信し、返り値をgetData変数に代入しています。 それぞれ挙動をテストしてみてください。 受信を押した時にfooが、送信を押したときに入力と同じ内容が表示されていれば成功です。 受信時 送信時 UIとServerの通信は以上です。 yarn serve及びyarn expressをCtrl + Cで停止してください。 7. おわりに ここまで読んでいただきありがとうございました。 Vue.jsはAngular、Reactといった仮想DOMを扱うフレームワークの中で最も学習コストが低いため、他のフレームワークの入り口としても有効なものだと思っています。 この記事の内容が少しでも皆様のお役に立てば幸いです。 今回作成したソースコードはこちらに配置しています。 8. おまけ1:Croudfoundryへのアップロード ここでは作成したアプリをIBMcloudにアップロードして誰でもアクセスできるようにする方法を紹介します。 AWSやGCP、Azureなどを使う場合と共通する点もありますが異なる点もあるのでその場合は各自リファレンスを読んで頑張ってください。 8.1. ソースコードのビルド まず、本番環境にアップロードするファイルを作るため、yarn buildを実行します。 完了するとプロジェクトの中にdistというフォルダが追加されます。 これは現時点のコードをコンパイルして、これ単体で動くようなファイルにまとめたものです。 こちらはyarn serveで実行した場合と異なり、ソースコードを書き換えても再度yarn buildを実行するまで変更されません。 distが確認できたら今度はyarn express:runを実行し、http://localhost:3000/#/testにアクセスしてみます。 (失敗する場合はyarn expressが動きっぱなしになっている可能性があります) yarn serve及びyarn expressを同時に実行していたときと同じ状態になっていると思います。 これはdistの中身とRESTapiserverを同時に動かすコマンドで、これが動作するのであれば本番環境でも動作する可能性が高いということになります。 これらの機能はvue-cliのパッケージを使って行われますが、vue-cliはデフォルトでは開発環境のみで動作するようになっています。 そこで、package.jsonを開き、devDependenciesの内容を全てdependenciesに移しておきましょう package.json { "name": "test-project", "version": "0.1.0", "private": true, "scripts": { "serve": "vue-cli-service serve", "build": "vue-cli-service build", "express": "vue-cli-service express:watch", "express:run": "vue-cli-service express:run" }, "dependencies": { "axios": "^0.21.1", "core-js": "^3.6.5", "vue": "^3.0.0", "vue-axios": "^3.2.5", "vue-router": "^4.0.0-0", "@vue/cli-plugin-babel": "~4.5.0", "@vue/cli-plugin-router": "~4.5.0", "@vue/cli-service": "~4.5.0", "@vue/compiler-sfc": "^3.0.0", "vue-cli-plugin-express": "~1.0.2" }, "devDependencies": {} } 8.2. ibmcloudの準備 IBM Cloud にログイン リソースの作成をクリック CloudFoundryで検索し、CloudFoundryをクリック 作成をクリック ロケーションをダラスに設定 リソースを SDK for Node.jsを選択 アプリ名を設定 作成をクリック (Liteアカウントで登録している場合、無料で使用することができます。リソースは30日間経つと削除されます) 以下を参考にIbmcloud cli toolをインストールしてください。 IBMCloud cli toolのインストール方法 8.3. アプリのデプロイ プロジェクトの一番上のディレクトリに.cfignoreファイルを作成し、以下の内容を書き込みます。 cfignore node_modules yarn.lock このファイルはCloudFoundryにアップロードしないファイルを設定するものです。 node_modulesはCloudFoundryにアップロードした際に自動でインストールされるため、時間短縮のために除外します。 また、yarn.lockは依存関係の都合上、入っているとデプロイに失敗するため除外します。 続いて同じディレクトリにmanifest.ymlファイルを作成し、以下の内容を書き込みます manifest.yml applications: - name: <8.2.で設定したアプリ名> routes: - route: <アプリ名>.<ドメイン名> memory: 256M disk_quota: 512M command: yarn express:run これはCloudFoundryにアップロードする際の設定ファイルです。 ここでCommandをyarn express:runに設定することでvue-cliを用いた起動が使えるようになります。 (設定しない場合、npm startが利用されます) ibmcloud login -r us-southと打ち込んでibmcloudにログインします。 続いてibmcloud target --cfと打ち込み、cloud foundryをアップデート先に設定します。 最後にibmcloud cf pushを入力します アプリが実行開始できたら アプリURLにアクセスします。 作成したアプリがアップロードできていればOKです 9. おまけ2:Watson Assistantとの連携 9.1. インスタンスの作成 IBM Cloud にログイン リソースの作成をクリック Watson Assistantで検索し、Watson Assistantをクリック ロケーションを適当に設定(CloudFoundryを使う場合は合わせる) ライトアカウントを選択 サービス名を設定 作成をクリック 9.2.チャットボットの作成 Watson Assistantの起動をクリック Create assistantをクリック 名前を設定してCreate assistantをクリック Add an actions or dialog skillをクリック 名前を設定、 LanguageをJapaneseに設定、 Skill typeをDialogに設定し Create skillをクリック 作成したDialog Skillをクリック 簡単のために構築済みIntentを利用します。 Content Catalogをクリック 一般カテゴリのインテントをAdd to skillで追加します Intentsタブで追加したIntentを確認できます。 また、Create Intentで新しいIntentを作成できます。 名前を設定し、5つ以上のサンプルを追加することで類似した入力を検知できるようになります。 Dialogタブに移動し、Add nodeでいくつかノードを追加します。 ノードを選択し、 Nameに名前 Enter conditionに認識するIntent(その他にも固有の語を認識するEntitiesや変数を認識するContextなどがある) Assistant respondsに返答を設定する これを作成したノード分行う。 設定が完了したら右上のTry itをクリック テキストを入力することで動作をテストできます。 9.3.アプリの実装 UI側 <template> <div> <div> <BotLog :log='log' v-for="log in logs"/> </div> <div> <input type='text' v-model='sendData.text'> <input type='button' value='送信' @click='addLog'> </div> </div> </template> v-modelで入力が連動するようにしたテキスト入力欄とログを追加する関数を実行する送信ボタン v-forでログ用コンポーネントを繰り返し表示する表示欄を作成します。 addLog(){ this.logs.push(Object.assign({},this.sendData)) //入力したデータをログに追加 this.axios.post('/sendMsg', this.sendData) //APIにデータ送信 .then((res) => { this.logs.push({ speaker: 'bot', text: res.data.text }) //APIの結果をログに追加 }) this.sendData.text = '' //入力内容を初期化 } Server側 ターミナルでyarn add dotenvを実行します。 これは環境変数を一時的に.envファイルから呼び出せるようにするパッケージです。 APIキーや接続先URLなどの資格情報はコード内に書き込むのではなく、 他のファイルに分離しておくと後で接続先が変わった時などに便利です。 特に、kubernetesなどを使ったマイクロサービス的なシステムを構築する際は、 環境変数をkubernetesのyamlで管理可能なため、資格情報などを環境変数から呼び出すように作っておくことが推奨されています。 project一番上のディレクトリ(package.jsonがあるディレクトリ)に.envファイルを作成します。 中身は URL="<Watson AssistantのインスタンスURL>" API_KEY="<作成したAssistantのAPIKEY>" ASSISTANT_ID="<作成したAssistantのID>" とします。 これらの情報は ①左上の一覧をクリック ②3点ボタンをクリック ③Settingを選択 の順で表示することができます。 ただし、Assistant URLはhttps://~ibm.comまでを使うようにしてください、これ以降を含めるとエラーになります。 /srv/index.jsを開き、import express from 'express';の下に require('dotenv').config();を追記します。 Assistant API リファレンスを開きます。 Nodeタブを選択し、説明に従って npm install ibm-watson@^6.1.2を実行します 左の目次からSend user input to assistant(stateless)を選びます。 (Statelessでは直前の会話を保存しません、会話の結果を利用して次のノードに移動する場合などはStatefulを利用してください) /sendMsgにPOSTメソッドを作成し、Example requestの内容を貼り付けます その後 '{apikey}'をprocess.env.API_KEY '{url}'をprocess.env.URL '{assistant_id}'をprocess.env.ASSISTANT_IDに書き換え input: { 'message_type': 'text', 'text': 'Hello', } の'text'をreq.body.textに変更し、 .then(res => { console.log(JSON.stringify(res.result, null, 2)); }) 部分を呼び出し元への返信に書き換えます。 .then(assistantResponse => { res.json({ text: assistantResponse.result.output.generic[0].text }); }) 完成形は下のようになります。 /srv/index.js app.post('/sendMsg', (req, res) =>{ const AssistantV2 = require('ibm-watson/assistant/v2'); const { IamAuthenticator } = require('ibm-watson/auth'); const assistant = new AssistantV2({ version: '2021-06-14', authenticator: new IamAuthenticator({ apikey: process.env.API_KEY, }), serviceUrl: process.env.URL, }); assistant .messageStateless({ assistantId: process.env.ASSISTANT_ID, input: { 'message_type': 'text', 'text': req.body.text, } }) .then(assistantResponse => { res.json({ text: assistantResponse.result.output.generic[0].text }); }) .catch(err => { console.log(err); }); }) 完成形は図のようになります CroudFoundryへのアップロード用 .cfignoreを開き、.envを追加します。 manifest.ymlを開き、 env: - URL: "<Watson AssistantのインスタンスURL>" - API_KEY: "<作成したAssistantのAPIKEY>" - ASSISTANT_ID: "<作成したAssistantのID>" を追加します 設定は以上です。