<template> に存在していない Vue コンポーネントを動的に追加
Vue インスタンスを作成して $mount
すればおk。
add() { const instance = new Bar({ propsData: { date: new Date().valueOf() } }) .$on("click", date => console.log(date)) .$mount(); document.body.appendChild(instance.$el); }
$mount
に elementOrSelector を設定、あるいは Vue インスタンスの生成時に el
を設定しても一応可能ですが、その場合「追加」ではなく「置換」になるので注意が必要です。
vue-append-instance-programmatically - StackBlitz
利用ケース
デバッグ用の View を追加したいけど、<DebugView v-if="isDebug" />
のように既存のコードに手を加えたくない場合にこんな感じで対応しました。
Nuxt の場合 plugin を利用して以下のように記述できると思います。
// plugins/routes-viewer/index.ts // 登録されている全てのルートを表示するコンポーネント import { Context } from '@nuxt/types'; import VueRouter, { RouterOptions } from 'vue-router'; import Viewer from './Viewer.vue'; export default (context: Context) => { const router = context.app.router; if (!router) { return; } const removeGuard = router.afterEach(() => { const instance = new Viewer({ propsData: { paths: pickPaths(router), }, }) .$on('click', (path: string) => router.push(path)) .$mount(); document.body.appendChild(instance.$el); removeGuard(); }); }; const pickPaths = (router: VueRouter) => { const routes = (router.options as RouterOptions).routes; if (!routes) { return []; } return routes.map(route => route.path).sort(); };
メモ
$el
$el
は Vue インスタンスが管理しているルート DOM 要素です。$mount される前はまだ存在しないです。
const instance = new Bar({ ... }); console.log(instance.$el); // => undefined instance.$mount(); console.log(instance.$el); // => <div>Bar<br>1578449862325</div>
new Bar()
or new Vue()
?
Bar
がプレーンなオブジェクトなのか、Vue.extend
を利用して生成された Vue コンストラクタのサブクラスなのか。
Bar
が Vue コンストラクタのサブクラスならばサンプル通りに new Bar()
が利用できます。そうではない場合、当然ですが new Bar()
ではエラーになります。new Vue({ ...Bar, /* */ });
のようになります。
生成した Vue インスタンス内で Router などが使えない
新たに作成した Vue インスタンスは、既存のルート Vue インスタンスとは別の管理になります。つまり、このままでは this.$router
や this.$store
にはアクセスできません。
新たに inject することもできますが、今回のようなケースでは追加されるコンポーネントには機能をあまりもたせず、追加する側で対応する方がよさそうに思えます。
TypeScript お試し環境をお手軽コピペコマンドで
小ネタです。コピペでおk。Mac なら動くと思います。
VS Code と VS Code ESLint extension をインストールしておくと ESLint の自動修正も動くので快適です。
echo mkdir mkdir ts-`date '+%Y%m%d%H%M%S'` && cd $_ echo typescript npm init -y && npm i -D typescript && node_modules/.bin/tsc --init && echo -e "const arr: number[] = [1, 3, 5];\nconsole.log(arr);" > index.ts # prettier 使いたい人はここも echo prettier npm i -D prettier && echo -e '{\n "singleQuote": true,\n "trailingComma": "es5"\n}' > .prettierrc # さらに eslint 使いたい人はここも echo eslint npm i -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier eslint-plugin-prettier && echo -e '{\n"root": true,\n"env": {\n"browser": true,\n"node": true\n},\n"extends": [\n"eslint:recommended",\n"plugin:@typescript-eslint/eslint-recommended",\n"plugin:@typescript-eslint/recommended",\n"plugin:prettier/recommended",\n"prettier/@typescript-eslint"\n],\n"rules": {}\n}' > .eslintrc && node_modules/.bin/prettier --write .eslintrc # さらに vscode で eslint の auto fix 使いたい人はここも echo vscode mkdir .vscode ; echo -e '{\n"editor.formatOnSave": false,\n"editor.codeActionsOnSave": {\n"source.fixAll.eslint": true\n}}' > .vscode/settings.json && node_modules/.bin/prettier --write .vscode/settings.json # git もお好みで echo git git init && echo node_modules/ > .gitignore && git add . && git commit -m 'init'
実行結果
. ├── .eslintrc ├── .git ├── .gitignore ├── .prettierrc ├── .vscode │ └── settings.json ├── index.ts ├── node_modules ├── package-lock.json ├── package.json └── tsconfig.json
何ができるの
typescript
のインストールtsc --init
を利用した TypeScript プロジェクトの初期化とtsconfig.json
の生成- ESLint と prettier のインストールと設定
- Lint には最低限と思われる recommended な設定だけいれてあります
eslint:recommended
,plugin:@typescript-eslint/recommended
各ルールがコンフリクトしないための設定もいれています
- Lint には最低限と思われる recommended な設定だけいれてあります
何に使うの
- ローカルで手軽に TypeScript の挙動確認をするため
- https://stackblitz.com/ や http://www.typescriptlang.org/play/ も便利でよく使いますが、TypeScript のコンパイラの挙動を確認したり npm install したパッケージのコードを確認したい時はローカルで動かしたほうが捗るので
- 正直
tsc --init
だけで十分です。フォーマット崩れが気になってしまう人向けです - すでに設定が完了している環境 (例えば開発中のアプリケーションや
npx create-xxx
的なもの) を使って挙動確認していると、前提知識や設定の違いでハマったりするので気をつけましょう - プロダクトに使うようなものではありません
その他
- 手軽に実行するなら
node_modules/.bin/tsc
- 出力先をかえたい
tsconfig.json
のoutDir
を変更
- コンパイル対象を変更したい
tsconfig.json
のfiles
,include
,exclude
を変更- 初期設定は
If the "files" and "include" are both left unspecified, the compiler defaults to including all TypeScript (.ts, .d.ts and .tsx) files in the containing directory and subdirectories
です - import 対象になっているファイルは、config 上で対象になっていなくてもコンパイル対象になります
- 読み込んでいる型定義ファイルを変更したい
tsconfig.json
のtypes
,typeRoots
を変更- 初期値は
all visible “@types” packages are included in your compilation.
です。node_modules/@types
などが対象になっています - 重要なこと
Keep in mind that automatic inclusion is only important if you’re using files with global declarations (as opposed to files declared as modules).
コンポーネントの横断的関心事を処理する方法 Mixins, HOCs, Render Props, Hooks を振り返る
- これは何か
- キーワード
- 横断的関心事とは
- Higher-Order Components
- Render Props
- Mixins
- Mixins => HOCs => Render Props => Hooks
- Vue Composition API
- 参考 URLs
これは何か
複数のコンポーネントが必要とする機能を共有可能にする方法を整理してみました。プロコンとこれから。基本的には 参考 URLs にあるドキュメントの切り貼りです。
キーワード
- Mixin
- 高階コンポーネント (Higher-Order Components)
- Render Props
- Hooks
横断的関心事とは
コンポーネントは React のコード再利用における基本単位です。しかし、いくつかのパターンの中には、これまでのコンポーネントが素直に当てはまらないことがあることに気づいたかもしれません。
具体例として以下のようなコンポーネントを考えてみます。
class CommentList extends React.Component { constructor(props) { /* ... */ } componentDidMount() { // Subscribe to changes DataSource.addChangeListener(this.handleChange); } componentWillUnmount() { // Clean up listener DataSource.removeChangeListener(this.handleChange); } handleChange() { // Update component state whenever the data source changes this.setState({ comments: DataSource.getComments() }); } render() { return ( <div> {this.state.comments.map((comment) => ( <Comment comment={comment} key={comment.id} /> ))} </div> ); } } // https://ja.reactjs.org/docs/higher-order-components.html
上のコードは CommentList だけですが、もう片方のコンポーネントも大部分の実装は同じになります。
- コンポーネントのマウント時に
DataSource
にイベントリスナを登録 - リスナの内部で
setState
をデータソースが変更されるたびに呼び出す - コンポーネントのアンマウント時にはイベントリスナを削除
横断的関心事とは、このような複数のコンポーネントに共通しているが1箇所にまとめるのが難しい関心事のことです。横断的関心事のロジックを1つの場所に定義し、複数のコンポーネントで共有可能にするために Mixins, HOCs, Render Props, Hooks といったテクニックが有用なことがあります。
Higher-Order Components
高階関数は「引数として関数をとる」「関数を戻り値として返す」関数です。では高階コンポーネントとはどういうものでしょうか。
const EnhancedComponent = higherOrderComponent(WrappedComponent);
前述の CommentList コンポーネントを高階コンポーネントを利用してリファクタリングすると以下のようになります。
withSubscription
が高階コンポーネントです。
const CommentListWithSubscription = withSubscription( CommentList, (DataSource) => DataSource.getComments() ); const BlogPostWithSubscription = withSubscription( BlogPost, (DataSource, props) => DataSource.getBlogPost(props.id) ); // https://ja.reactjs.org/docs/higher-order-components.html
function withSubscription(WrappedComponent, selectData) { return class extends React.Component { constructor(props) { /* ... */ } componentDidMount() { DataSource.addChangeListener(this.handleChange); } componentWillUnmount() { DataSource.removeChangeListener(this.handleChange); } handleChange() { this.setState({ data: selectData(DataSource, this.props) }); } render() { // ... and renders the wrapped component with the fresh data! // Notice that we pass through any additional props return <WrappedComponent data={this.state.data} {...this.props} />; } }; } // https://ja.reactjs.org/docs/higher-order-components.html
より具体的な説明:
HOC は入力のコンポーネントを改変したり、振る舞いをコピーするのに継承を利用したりしません。むしろ HOC は元のコンポーネントをコンテナコンポーネント内にラップすることで組み合わせるのです。HOC は副作用のない純関数です。
... 外側にある HOC は渡すデータが使われる方法や理由には関心がありませんし、ラップされたコンポーネントの側はデータがどこからやって来たのかには関心を持ちません。
アンチパターンとして:
コンポーネントの改変を行うような HOC は不完全な抽象化です。つまり、利用する側は他の HOC との競合を避けるため、どのように実装されているかを知っておく必要があるのです。
Vue と Higher-Order Components
高階コンポーネントはフレームワークが提供する機能ではなく汎用的な考え方なので、他のフレームワークでも実現可能です。
- Discussion: Best way to create a HOC · Issue #6201 · vuejs/vue
- VeeValidate 3 ではバリデーションの振る舞いを追加するための
withValidation
という 関数が用意されています
Render Props
“レンダープロップ (render prop)”という用語は、値が関数である props を使って、コンポーネント間でコードを共有するためのテクニックを指します。
... より具体的に述べると、レンダープロップとは、あるコンポーネントが何をレンダーすべきかを知るために使う関数の props です。
<DataProvider render={data => ( <h1>Hello {data.target}</h1> )}/>
具体例として「マウスに追従する猫」を以下の2つにわけて実装してみます。
class MouseTracker extends React.Component { render() { return ( <div> <h1>Move the mouse around!</h1> <Mouse render={mouse => ( <Cat mouse={mouse} /> )}/> </div> ); } } // https://ja.reactjs.org/docs/render-props.html
class Cat extends React.Component { render() { const mouse = this.props.mouse; return ( <img src="/cat.jpg" style={{ position: 'absolute', left: mouse.x, top: mouse.y }} /> ); } } class Mouse extends React.Component { constructor(props) { /* ... */ } handleMouseMove(event) { this.setState({ x: event.clientX, y: event.clientY }); } render() { return ( <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}> {/* Instead of providing a static representation of what <Mouse> renders, use the `render` prop to dynamically determine what to render. */} {this.props.render(this.state)} </div> ); } } // https://ja.reactjs.org/docs/render-props.html
Vue の Scoped Slots
Higher-Order Components と同様に Rendre Props も機能 (API) ではなく考え方です。それゆえ Vue でも同様な考え方を利用してコンポーネントのロジック共有が可能です。
共通化させたい振る舞いをもつコンポーネントに何をレンダーさせるか動的に決定させる方法として Vue の場合 Scoped Slots を利用することができます。
In case you are wondering what’s the equivalent pattern in Vue, it’s called scoped slots (and if using JSX it works the same as React)
以下は VeeValidate の ValidationProvider
を利用した実装例です。
<ValidationProvider name="name" rules="required" v-slot="{ errors }"> <input v-model="name"> <p>{{ errors[0] }}</p> </ValidationProvider>
Render Props と Higher-Order Components
ミックスインの問題を解決させる方法として HOCs が広まったようですが、HOCs にもいくつかの問題があったようです。以下は Reneder Props でそのあたりも解決できるよ、という話。
This technique avoids all of the problems we had with mixins and HOCs:
- Indirection. We don’t have to wonder where our state or props are coming from. We can see them in the render prop’s argument list.
- Naming collisions. There is no automatic merging of property names, so there is no chance for a naming collision.
...
Additionally, the composition model here is *dynamic*!
Mixins
コンポーネントのロジック共有のテクニックとしてミックスインがどうして利用されなくなったのかでしょうか。
以前に横断的関心事を処理する方法としてミックスインをお勧めしました。私たちはその後にミックスインはそれが持つ価値以上の問題を引き起こすことに気づきました。ミックスインから離れる理由と、既存のコンポーネントを移行する方法についてはこちらの詳細な記事を読んでください。
上の引用にもある Mixins Considered Harmful – React Blog がよくまとまっていました。私が利用していたのは Vue のミックスインですが、ほぼ共感できる内容でした。以下は記事内の "Why Mixins are Broken" より:
- Mixins introduce implicit dependencies
- Mixins cause name clashes
- 例えば適用させる複数のミックスインが同じメソッド名を持つ場合
- Mixins cause snowballing complexity
Mixins => HOCs => Render Props => Hooks
フックは React 16.8 から追加された React の API です。フックを導入した動機が フックの導入 – React に書かれています。
ステートフルなロジックをコンポーネント間で再利用するのは難しい
... React をしばらく使った事があれば、この問題を解決するためのレンダープロップや高階コンポーネントといったパターンをご存じかもしれません。しかしこれらのパターンを使おうとするとコンポーネントの再構成が必要であり、面倒なうえにコードを追うのが難しくなります。典型的な React アプリを React DevTools で見てみると、おそらくプロバイダやらコンシューマやら高階コンポーネントやらレンダープロップやら、その他諸々の抽象化が多層に積み重なった『ラッパー地獄』を見ることになるでしょう。... React にはステートフルなロジックを共有するためのよりよい基本機能が必要なのです。
フックを使えば、ステートを持ったロジックをコンポーネントから抽出して、単独でテストしたり、また再利用したりすることができます。フックを使えば、ステートを持ったロジックを、コンポーネントの階層構造を変えることがなく再利用できるのです。このため、多数のコンポーネント間で、あるいはコミュニティ全体で、フックを共有することが簡単になります。
Render Props や HOCs との関係についても書かれています。
フックはレンダープロップや高階コンポーネントを置き換えるものですか?
レンダープロップや高階コンポーネントは、ひとつの子だけをレンダーすることがよくあります。フックはこのようなユースケースを実現するより簡単な手段だと考えています。これらのパターンには引き続き利用すべき場面があります(例えば、バーチャルスクローラーコンポーネントは
renderItem
プロパティを持つでしょうし、コンテナコンポーネントは自分自身の DOM 構造を有しているでしょう)。とはいえ大抵の場合ではフックで十分であり、フックがツリーのネストを減らすのに役立つでしょう。
ロジック共有のためにコンポーネントを利用する必要なんてないんですよ。
Vue Composition API
Vue に React Hooks のような関数ベースのロジック合成 API は追加されるのでしょうか。
( ´・ω) (´・ω・) (・ω・`) (ω・` ) ネー
Vue 3.0 Discards Class-Based API for Reusable, Composable Function-Based Approach
As a composition mechanism, hooks is objectively better than mixins, HOCs and render props. I think we will try to find a way to allow Vue users leverage its power in a way that complements Vue's idiomatic API.
Composition API RFC | Vue Composition API:
ここで詳しい情報を確認できるようです。
Motivation
Logic Reuse & Code Organization
...
Better Type Inference
...
Code Organization
...
But as mentioned in the Motivations section, we believe the Composition API actually leads to better organized code, particularly in complex components. Here we will try to explain why.
What is "Organized Code"?
...
export default { setup() { // ... } } function useCurrentFolderData(networkState) { // ... } function useFolderNavigation({ networkState, currentFolderData }) { // ... } function useFavoriteFolder(currentFolderData) { // ... } function useHiddenFolders() { // ... } function useCreateFolder(openFolder) { // ... }
Logic Extraction and Reuse
The Composition API is extremely flexible when it comes to extracting and reusing logic across components.
...
import { ref, onMounted, onUnmounted } from 'vue' export function useMousePosition() { const x = ref(0) const y = ref(0) function update(e) { x.value = e.pageX y.value = e.pageY } onMounted(() => { window.addEventListener('mousemove', update) }) onUnmounted(() => { window.removeEventListener('mousemove', update) }) return { x, y } } export default { setup() { const { x, y } = useMousePosition() // other logic... return { x, y } } }
( ^ω^)
参考 URLs
ESLint の設定 (Nuxt TypeScript)
Nuxt で開発する時の ESLint の設定ファイルです。Nuxt のバージョンは 2.10 です。
前提
構文チェックに ESLint, コードフォーマットには Prettier を利用します。eslint-plugin-prettier
を利用して ESLint 経由で Prettier を動かします。
参考:
Integrating with Linters · Prettier
設定ファイル
npm i -D @nuxtjs/eslint-config-typescript eslint eslint-config-prettier eslint-plugin-nuxt eslint-plugin-prettier prettier
// .eslintrc.js module.exports = { root: true, extends: [ 'eslint:recommended', '@nuxtjs/typescript', 'plugin:nuxt/recommended', 'plugin:vue/strongly-recommended', 'plugin:prettier/recommended', 'prettier/vue' ], rules: { // Add rules } }
ESLint の設定ファイルを読む
ざっくりです。
基本知識
rules
に設定されたルールがチェックされます。
module.exports = { rules: { semi: ['error'] } }
ESLint のルール以外の構文チェックを設定したい場合 plugins
を追加します。プラグインの名前から eslint-plugin-
を省略することができます。eslint-plugin-vue
なら vue
で設定可能です。
module.exports = { plugins; ['vue'], rules: { 'vue/html-self-closing': ['error'] } }
参考:
https://eslint.org/docs/user-guide/configuring#configuring-rules
https://eslint.org/docs/user-guide/configuring#configuring-plugins
実際に適用されるルールを確認
ESLint には --print-config path::String
というコマンドが用意されています。これを利用することで、最終的にどのような設定が適用されるのか確認ができます。
node_modules/.bin/eslint --print-config pages/index.vue
{ "env": { "browser": true, "node": true, "es6": true, "jest/globals": true }, "globals": { "document": false, "navigator": false, "window": false }, "parser": "<PATH_TO_PROJECT>/node_modules/vue-eslint-parser/index.js", "parserOptions": { "ecmaVersion": 2018, "sourceType": "module", "ecmaFeatures": { "jsx": true }, "parser": "@typescript-eslint/parser" }, "plugins": [ "standard", "promise", "node", "import", "unicorn", "jest", "@typescript-eslint", "nuxt", "vue", "prettier" ], "rules": { "vue/html-self-closing": [ 0 ], "vue/array-bracket-spacing": [ "off" ], // ... (数が多いので省略) }, "settings": { "import/resolver": { "node": { "extensions": [ ".js", ".mjs" ] } } } }
.eslintrc.js
にはなかった plugins
や rules
の設定が追加されているのが確認できると思います。
設定ファイルを拡張する extends
extends
を利用すると既存の設定ファイルを再利用することができます。npm から設定ファイルをインストールして利用することも可能です。
参考:
https://eslint.org/docs/2.0.0/user-guide/configuring#extending-configuration-files
命名規則
plugins
と同様に extends
も名前を省略して設定することができます。eslint-config-
が省略可能です。
プラグイン内の設定ファイルを利用する場合は plugin:
を先頭につけます。名前の先頭の eslint-plugin-
はここでも省略可能です
最初の設定ファイルを省略なしで記述すると以下のようになります。
extends: [ 'eslint:recommended', '@nuxtjs/eslint-config-typescript', 'plugin:eslint-plugin-nuxt/recommended', 'plugin:eslint-plugin-vue/strongly-recommended', 'plugin:eslint-plugin-prettier/recommended', 'eslint-config-prettier/vue' ],
命名規則がわかると利用されているパッケージ名がわかるので確認しやすくなると思います。 (この記事で言いたいことはこれくらいかもしれない)
@nuxtjs/eslint-config-typescript
試しに1つ、今回利用している設定ファイルを確認してみます。
eslint-config/packages/eslint-config-typescript at master · nuxt/eslint-config · GitHub
module.exports = { extends: [ '@nuxtjs' ], plugins: ['@typescript-eslint'], parserOptions: { parser: '@typescript-eslint/parser' }, rules: { '@typescript-eslint/no-unused-vars': ['error', { args: 'all', argsIgnorePattern: '^_' }] } }
内部で @nuxtjs/eslint-config
, @typescript-eslint/eslint-plugin
, @typescript-eslint/parser
を利用しているのがわかります。
いつ ESLint を走らせるのか
(ここの説明は雑です)
手動で
eslint --ext .ts,.vue, .
eslint --ext .ts,.vue --fix .
コミット時
husky
の pre-commit
hook に lint-staged
を設定することで、変更したファイルのみを ESLint の対象にする。
ファイル保存時
VSCode を利用している場合
{ "eslint.validate": ["javascript", "typescript", "vue"], "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, "editor.formatOnSave": false }
保存時に ESLint を実行させます。必要に応じて "editor.formatOnSave": false,
のように既存処理が邪魔をしない設定も追加してください。
Date と Timezone
ちょっとしたメモです。でてくるサンプルコードは JavaScript です。
- ISO 8601とは
- 日付と時刻の表記に関するISOの国際規格
2010-10-10T00:00:00.000+09:00
みたいなやつ- ISO 形式 (ISO 8601) の文字列判定 - ryotah’s blog
- 1888年以前は東京のオフセットは +09:18:59 なんだそうです
new Intl.DateTimeFormat('ja-JP', { dateStyle: 'long', timeStyle: 'long' }).format(new Date(-2587712400000)) // => "1888年1月1日 0:00:00 GMT+9" new Intl.DateTimeFormat('ja-JP', { dateStyle: 'long', timeStyle: 'long' }).format(new Date(-2587712400001)) // => "1888年1月1日 0:18:58 GMT+9:18:59"
- Date オブジェクトは関係演算子 (
==
とか>
とか) で比較可能- 比較演算子 - JavaScript | MDN
moment
やluxon
などの Date 系ライブラリで生成されたオブジェクトが関係演算子で比較できるのも、valueOf
を持っているから- https://moment.github.io/luxon/docs/manual/math.html#comparing-datetimes
DateTime implements #valueOf to return the epoch timestamp, so you can compare DateTimes with <, >, <=, and >=.
- 厳密等価演算子 (
===
) は型変換なしで比較をするので注意 (同時刻だとしてもtrue
にはならない)
- https://moment.github.io/luxon/docs/manual/math.html#comparing-datetimes
- IANA timezone name とは
America/Los_Angeles
,Asia/Tokyo
のようなタイムゾーンを表すことができる名前- https://www.iana.org/time-zones
The Time Zone Database (often called tz or zoneinfo) contains code and data that represent the history of local time for many representative locations around the globe
- https://ja.wikipedia.org/wiki/Tz_database
- IANA timezone と IE11
- IE 11 でも
Intl.DateTimeFormat
は利用できるが IANA timezone name には対応していない - http://kangax.github.io/compat-table/esintl/#test-DateTimeFormat_accepts_IANA_timezone_names
- (DateTimeFormat の詳細を開けて確認してください)
- IE 11 でも
- いつタイムゾーンを気にすべきか
import { DateTime, Settings } from "luxon"; Settings.defaultZoneName = 'Asia/Tokyo'; /** * 生成 */ console.log(DateTime.fromISO("2019-10-05T14:00:00").valueOf()); // => 1570251600000 console.log(DateTime.fromISO("2019-10-05T14:00:00", { zone: "America/Los_Angeles" }).valueOf()); // => 1570309200000 const tokyo = DateTime.fromISO("2019-10-05T14:00:00"); /** * 取得, 変更 */ console.log(tokyo.hour); // => 14 console.log(tokyo.setZone("America/Los_Angeles").hour); // =>22 console.log(tokyo.startOf('day')); // `startOf("day")` で「日」の開始に変更 console.log(tokyo.startOf('day').valueOf()); // => 2019-10-05T00:00:00.000+09:00 // => 1570201200000 console.log(tokyo.setZone("America/Los_Angeles").startOf('day')); console.log(tokyo.setZone("America/Los_Angeles").startOf('day').valueOf()); // => 2019-10-04T00:00:00.000-07:00 // => 1570172400000 /** * 表示 */ console.log(tokyo.toLocaleString(DateTime.DATETIME_SHORT)); // => 10/5/2019, 2:00 PM console.log(tokyo.setZone("America/Los_Angeles").toLocaleString(DateTime.DATETIME_SHORT)); // => 10/4/2019, 10:00 PM
Nuxt のモジュールをテストする
以下のような構成のモジュールをつくったとして、テストをどのように書くことができるか整理します。
. ├── README.md ├── __tests__ │ ├── fixtures │ │ ├── nuxt.config.js │ │ └── pages │ │ └── index.vue │ └── index.js ├── jest.config.js ├── lib │ ├── module.js │ └── plugin.template.js ├── package-lock.json └── package.json
モジュールは「Google Analytics を設定するプラグインを提供するモジュール」として話を進めます。
モジュール本体
実際の機能を提供しているファイルは lib/module.js
, lib/plugin.template.js
です。
// module.js const path = require('path'); module.exports = async function Module(moduleOptions) { const options = { // Default values id: undefined, ...this.options['ga'], // options via nuxt.config.js ...moduleOptions, }; this.addPlugin({ src: path.resolve(__dirname, 'plugin.template.js'), fileName: 'ga.js', options, ssr: false, }); };
// plugin.template.js export default ({ app }) => { const moduleOptions = <%= serialize(options) %>; if (!id) { return; } // Load analytics.js (function(i, s, o, g, r, a, m) { // ... })( window, document, 'script', 'https://www.google-analytics.com/analytics.js', 'ga' ); // Create a tracker ga('create', id, 'auto'); app.router.afterEach(to => { // Update the tracker ga('set', 'page', to.fullPath); // Send a pageview hit ga('send', 'pageview'); }); };
詳細な説明は省きますが、モジュールオプションか nuxt.config.js
のトップレベルのオプションを利用して GA のトラッキング ID を設定することが可能です。トラッキング ID が設定されない場合は GA のスクリプトがロードされないようになっています。
テストの方針
Nuxt のモジュールは Nuxt がビルドされる時に呼び出されます。そのため、モジュール関連のテストをするには Nuxt をプログラムから利用する必要があります(*)。
今回はテスト用に nuxt.config.js
と pages/index.vue
を用意し、テストコード内では Nuxt インスタンスを作成 → ビルド → サーバ起動 を行い、実行結果が期待したものになるか確認をします。
(*) すべてのケースでこのように Nuxt をプログラムから利用する必要はないと思っています。自分が作成したロジックだけをテストするのであれば、別の方法も有効かもしれません。今回のテストも、プラグインのテストだけなら plugin.template.js
を lodash template でコンパイルするという選択もあります。Nuxt をビルドするテストは時間がかかるという点も忘れない方がいいです。
(WIP) テストファイル
(記事は途中ですが、以下コードは問題なく動作します。現段階だと説明がわかりにくいだけです。)
// __tests__/index.js const { Nuxt, Builder } = require('nuxt'); process.env.PORT = process.env.PORT || 3000; const config = require('./fixtures/nuxt.config'); const url = path => `http://localhost:${process.env.PORT}${path}`; // Nuxt のビルドには時間がかかるので jest のデフォルトタイムを変更 // https://jestjs.io/docs/ja/jest-object#jestsettimeouttimeout jest.setTimeout(60000); describe('ga module', () => { let nuxt; let addTemplate; /** * テスト用に用意した `nuxt.config` を利用して Nuxt インスタンスを生成 * ビルドが完了したらサーバを起動 */ const initNuxt = async config => { if (nuxt) { await clearNuxt(); } // https://ja.nuxtjs.org/api/nuxt#nuxt-%E3%81%AE%E3%82%B3%E3%83%B3%E3%82%B9%E3%83%88%E3%83%A9%E3%82%AF%E3%82%BF nuxt = new Nuxt(config); addTemplate = nuxt.moduleContainer.addTemplate = jest.fn( nuxt.moduleContainer.addTemplate ); await new Builder(nuxt).build(); // https://github.com/nuxt/nuxt.js/blob/8786ff731767425bb0c1c72af6d8a8895155acf5/packages/server/src/server.js#L225 await nuxt.server.listen(process.env.PORT); }; /** * Nuxt サーバーをクローズする */ const clearNuxt = async () => { await nuxt.close(); }; /** * addTemplate (jest のモック関数) を利用してテンプレートオプションを取得 */ const getTemplateOptions = () => { const call = addTemplate.mock.calls.find(args => args[0].src.includes('plugin.template.js') ); return call && call[0] && call[0].options; }; afterEach(async () => { await clearNuxt(); }); test('installs ga script and sets trackingId with Nuxt options', async () => { // トップレベルオプションに `ga` 設定を追加 await initNuxt({ ...config, ga: { id: 'WITH_NUXT_OPTIONS', }, }); // https://ja.nuxtjs.org/api/nuxt-render-and-get-window // > Nuxt.js アプリケーションの URL を渡して window を取得します。 // // `__tests__/fixtures/pages/index.vue` がレンダーされます const window = await nuxt.renderAndGetWindow(url('/')); expect(window.ga).toBeDefined(); expect(getTemplateOptions().id).toBe('WITH_NUXT_OPTIONS'); }); });
// __tests__/fixtures/nuxt.config.js const { resolve } = require('path'); module.exports = { srcDir: __dirname, // https://ja.nuxtjs.org/api/configuration-dev // // https://github.com/nuxt/nuxt.js/blob/8786ff731767425bb0c1c72af6d8a8895155acf5/packages/builder/src/builder.js#L57 // dev が true だと監視状態になります。無効にしておいたほうがよさそうです。 dev: false, // @@ はエイリアスで `rootDir` を指しています。 // https://ja.nuxtjs.org/guide/directory-structure/#%E3%82%A8%E3%82%A4%E3%83%AA%E3%82%A2%E3%82%B9-%E5%88%A5%E5%90%8D- modules: ['@@'], };
参考
- https://github.com/nuxt-community/awesome-nuxt#modules
- オフィシャルのモジュールを参考に
- https://ja.nuxtjs.org/api/nuxt
- Nuxt.js をプログラムで使う
nuxt.renderRoute(route, context = {})
nuxt.renderAndGetWindow(url, options = {})