Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
342
Help us understand the problem. What is going on with this article?
@amishiro

Nuxt.js(v2)でgenerate納品する前にやっておきたい設定

Nuxt.js(v2)でgenerate納品する前にやっておきたい設定

リリース情報確認URL

動作確認済みバージョン

  • Node.js(v14.8.0)
  • npm(v6.14.7)
  • Nuxt.js(2.14.4)
  • create-nuxt-app(v3.2.0)
  • macOS Catalina(v10.15.6)
  • terminal(zsh)
  • VScode(v1.48.2)

更新履歴

  • 2020/9/1 nuxt.js(v2.13)から、generateプロパティーにroutesを設定しなくても大丈夫なようになっていたので該当箇所を削除。
  • 2020/8/29 create-nuxt-app(v3.2.0)用にリライトしました。
  • 2020/8/29 crieate-nuxt-appの古いバージョンが実行される問題についてライティングしました。
  • 2020/8/29 stylelintが公式のVScode拡張機能を出したのでリライトしました。
  • 2020/8/29 @nuxtjs/google-tag-managerが@nuxtjs/gtmへ統合されたのでリライトしました。
  • 2020/8/29 デモとコードをGithubへアップしました(おまけ1を確認)。
  • 2020/8/29 他、細々とリライトしました。
  • 2020/2/10 create-nuxt-app(v2.12.0→2.14.0)アップデートに対応。stylelint.config.jsのrulesプロパティ初期設定が直ってます。Semantic Pull Requestsの設定ができる様になっています。
  • 2020/2/7 textlintチェックで表記を修正。
  • 2020/2/1 最新のバージョンに合わせて大幅にリライトしました。styleLintの項やVScodeの自動整形などが追加されています。リライトにより更新履歴が意味をなさなくなったので、他更新履歴を削除しました。

インストール前に...

Node.jsのバージョンを確認

% node -v

インストール前にNode.jsのバージョンを確認します。最低限必要なノードのバージョンはv8.9.0です。息の長い開発をするならば、LTS(推奨版)の最新をお勧めします。

npmのキャッシュを削除

% npm cache clean --force

npmのキャッシュにcreat-nuxt-appパッケージが入っている場合、キャッシュを優先的に参照してしまいます。念のため、キャッシュを削除します。

% npx create-nuxt-app -v 
↓
create-nuxt-app/x.x.x

-vオブションでcreate-nuxt-appのバージョンが意図したものであることを確認します。

Nuxt.jsのインストール

インストールの開始

% npx create-nuxt-app <構築先のディレクトリ>

公式ドキュメントのcreate-nuxt-appを利用するを参考に、各種設定をしつつ選んでNuxt.jsをインストールします。

Tips:create-nuxt-appのv3は、構築先に指定できるディレクトリが「空のディレクトリのみ」となりました。既存上書き事件が頻発したからかな。すでに何かしらのファイルが存在する場合は、インストール後に構築されたファイルを移動してください。

インストール時の各種質問

公式ドキュメントに従い質問に回答します。本項では、個人的に注意している点と好みを書きますので、プロジェクトに合わせて変更してください。

プロジェクトの名前入力

? Project name  <プロジェクト名>

インストール後に変更可能です。

入力したプロジェクト名は、自動生成されるpackage.jsonのnameに記載されます。気になる方はnpm-package.jsonに記載された仕様をご確認ください。普通は、それほど気にしなくてもいいでしょう。

Tips:以下は、nameプロパティルールの日本語訳の抜粋略です。

いくつかのルール:

  • 名前は214文字以下。
  • ドットまたはアンダースコアで始めることはできません。
  • パッケージの名前に大文字を含めることはできません。
  • URLセーフでない文字を含めることはできません。

いくつかのヒント:

  • コアノードモジュールと同じ名前を使用しないでください。
  • 名前に「js」または「node」を入れないでください。
  • 短い名前にする必要がありますが、合理的に説明する必要があります。

プログラミング言語の選択

? Programming language: (Use arrow keys)
❯ JavaScript 
  TypeScript 

インストール後の変更は辛いです。テンキーで選択カーソルを移動できます。

通常はJavaScriptを選択します。慣れている人が多い、静的型付けが必要なプロジェクトであればTypeScriptを推奨します。個人的にはTypeScriptは安心感があって好きですが、特に理由がなければJavaScriptを選択します。

パッケージマネージャーの選択

? Package manager: (Use arrow keys)
  Yarn
❯ Npm

インストール後の変更は辛いです。テンキーで選択カーソルを移動できます。

通常はnpmを選択します。多くのドキュメントがnpmベースで記載されている、新たにyarnのバージョン管理方法を学ばなくて良いため、学習コストを削減できるからです。個人的にはyarnは安心感があって好きですが、特に理由がなければnpmを選択します。

UIフレームワークを選択

? UI framework: (Use arrow keys)
❯ None 
  Ant Design Vue 
  Bootstrap Vue 
  Buefy 
  Bulma 
  Chakra UI 
  Element 
  Framevuerk 
  iView 
  Tachyons 
  Tailwind CSS 
  Vuesax 
  Vuetify.js 

インストール後に追加可能です。テンキーで選択カーソルを移動できます。

通常はNoneを選択します。プロジェクトメンバーで利用するフレームワークを検討の上で別途追加します。また、UIフレームワークのパッケージアップデートを行うのでクリーンな状態を好みます。

Nuxtモジュールをチェック

? Nuxt.js modules: (Press <space> to select, <a> to toggle all, <i> to invert selection)
 ◯ Axios
❯◉ Progressive Web App (PWA)
 ◯ Content

インストール後に追加可能です。テンキーで選択カーソルを移動しながら、スペースキーでチェックを入れます。

通常は、PWAをチェックします。APIを利用する場合はAxiosを、マークダウンでコンテンツを作成したい場合はContentも選択します。

Tips:Nuxt.jsのPWAモジュールは細かな設定をしなくてもある程度のPWA効果が出ます。

リンティングツールをチェック

? Linting tools:(Press <space> to select, <a> to toggle all, <i> to invert selection)
 ◉ ESLint
 ◯ Prettier
 ◉ Lint staged files
❯◉ StyleLint

インストール後に追加可能です。テンキーで選択カーソルを移動しながら、スペースキーでチェックを入れます。

通常は、ESLint、Lint staged files、StyleLintをチェックします。Prettierは好みが分かれるため、プロジェクトメンバーで検討の上で別途追加します。

Tips:自動整形は、整形タイミングの好みが分かれるためVScodeなどのエディター設定で指定します。

テストフレームワークを選択

? Testing framework: (Use arrow keys)
  None 
❯ Jest 
  AVA 
  WebdriverIO 

インストール後に追加可能です。テンキーで選択カーソルを移動できます。

通常は、Jestを選択します。複雑なコンポーネントを作るときにテストが必要になることがあるためです。使わなくても選択した影響はありません。

Tips:仕様書がわりにテストを共有している姿はカッコイイですよね。

レンダリングモードを選択

? Rendering mode: (Use arrow keys)
❯ Universal (SSR / SSG) 
  Single Page App 

インストール後に変更可能です。テンキーで選択カーソルを移動できます。

通常は、Universal (SSR / SSG)を選択します。ネイティブアプリを作成するなど特殊な事情がない限りSPAを選択するメリットは少ないはずです。

Tips:Single Page App(SPA)モードはgenerateしたときにページ毎のmeta関連が反映されません。

参考:Nuxt.js(v2)でSEOに必要なmeta(OGP)を入れたい

ホスティング先を選択

? Deployment target: (Use arrow keys)
  Server (Node.js hosting) 
❯ Static (Static/JAMStack hosting) 

インストール後に変更可能です。テンキーで選択カーソルを移動できます。

通常は、Static (Static/JAMStack hosting)を選択します。generate前提なので、Server (Node.js hosting) は選びません。

開発ツールをチェック

? Development tools: (Press <space> to select, <a> to toggle all, <i> to invert selection)
❯◉ jsconfig.json (Recommended for VS Code if you're not using typescript)
 ◯ Semantic Pull Requests

インストール後に追加可能です。テンキーで選択カーソルを移動しながら、スペースキーでチェックを入れます。

通常は、jsconfig.jsonをチェックします。Nuxt.js独自の記法である~/@/をVScodeが解釈してくれるようになります。Semantic Pull Requestsはcommit時のタイトルを矯正しますのでプロジェクトに合わせて利用を検討してください。

インストール前の質問は以上です。

動作テスト

🎉  Successfully created project <プロジェクト名>

  To get started:

        cd <構築先のディレクトリ>
        npm run dev

  To build & start for production:

        cd <構築先のディレクトリ>
        npm run build
        npm run start

  To test:

        cd <構築先のディレクトリ>
        npm run test

回答終了後、node_module、Nuxt.jsファイル群のインストールが自動で始まります。成功すると🎉 Successfully created project <プロジェクト名>と表示されます。

% cd <構築先のディレクトリ>
% npm run dev

デベロッパーモードで起動して、エラーが出なければ動作確認完了です。

Tips:構築先のディレクトリを変更したい場合は、このタイミングで移動しましょう。

作業開始前の設定

作業開始前に実施しておくべき設定をまとめます。後からでも変更は可能ですが、納品前に心を折られないように備えておきます。

ディレクトリ関連

srcDirの設定

nuxt.config.js
module.exports = {
  srcDir: 'src/', // ←srcDirプロパティを追加
  ...
}

ディレクトリの移動

% mkdir src
% mv assets components layouts pages plugins static store middleware src/

いつでも変更可能です。

ディレクトリ構造が複雑化する前に、Nuxt.jsのメインディレクトリassets components layouts pages plugins static store middlewareを「src」ディレクトリにまとめます。

Tips:公式ドキュメントではclientディレクトリにまとめていますが、プロパティ名srcDirに合わせて「src」ディレクトリにまとめます。

Tips:いつでも変更可能ですが、ディレクトリ構造が複雑化した際には手遅れの場合があります。変更後(または変更前)は、公式ドキュメントを確認の上、エイリアス(~/~~/@/@@/)の違いに注意してください。

参考:Nuxt.js(v2)の作業ディレクトリを整理

ベースディレクトリをgenerate時に変更できるようにする

nuxt.config.js
const baseDir = process.env.BASE_DIR || '/' // ←環境変数の指定を追加

module.exports = {
  router: {
    base: baseDir, // ←rooterBaseの指定を追加
  },
  ...
}

generate方法

MAC
↓
% BASE_DIR=/yourDir/ npm run generate

WINDOWS
↓
% npx cross-env BASE_DIR=/yourDir/ nuxt generate

いつでも変更可能です。

作業後に格納ディレクトリを指定された場合に備え、routerプロパティのbaseをnodeの環境変数で変更できるようにします。作業中にリンクぎれが起きてないか確認します。

Tips:ディレクトリが決まっている場合はnpm-scriptgenerate:production generate:stagingなどを追記します。

Tips:汎用性を高くする場合(ディレクトリがよく変わる場合)は、minimistでオプションを利用します。minimistパッケージは、Nuxt.jsインストール時に入っています。

参考:Nuxt.js(v2)のベースディレクトリをターミナルから変更する

IE11対応関連

CSS・JavaScriptをIE11に対応

特に必要はありません(cool!)。

Tips:対応状況はbrowserslistで確認します。browserslistパッケージは、Nuxt.jsインストール時に入っています。

% npx browserslist
↓
and_chr 84
and_ff 68
and_qq 10.4
and_uc 12.12
android 81
baidu 7.12
chrome 85
chrome 84
chrome 83
edge 84
edge 83
edge 18
firefox 80
firefox 79
firefox 78
firefox 77
firefox 68
ie 11
ios_saf 13.4-13.5
ios_saf 13.3
ios_saf 12.2-12.4
kaios 2.5
op_mini all
op_mob 46
opera 69
opera 68
safari 13.1
safari 13
samsung 12.0
samsung 11.1-11.2

autoprefixerのgrid対応

いつでも変更可能です。

公式ドキュメントのpostcssを参考にnuxt.config.jsファイルを修正します。Nuxt.jsの公式ドキュメントでは{ grid: true }を利用してますが、trueは廃止予定なのでautoprefixerのドキュメントを参考に'autoplace'を指定します。

nuxt.config.js
export default {
  build: {
    postcss: {
      preset: {
        autoprefixer: { grid: 'autoplace' }
      }
    }
  },
...

Tips:Nuxt.jsのデフォルトはfalseです。

Tips:'autoplace'を指定すると、repeat()(自動配置の一部)を有効にできます。しかし、auto-fill minmax()などの自動配置はIE11では再現できません。

Tips:trueと同等の動作はno-autoplaceです。

一部のメソッド用にpolyfillを読み込む

いつでも変更可能です。

polyfill.ioで生成したURLを、nuxt.config.jsのheadプロパティーから読み込みます。

nuxt.config.js
export default {
  head: {
    script: [
      { src: '//polyfill.io/v3/polyfill.min.js?features=es2015%2CWebAnimations%2CIntersectionObserver' }
    ],
  },
...

Tips:追加で必要になった場合は?features=の後にコンマ区切りで追記します。

Tips:polifill.ioは、ブラウザに適したpolyfillのみを返します(cool!)。

参考:Nuxt.js(v2)でIE11対応をする(CSS編)nuxt2でIE11対応をする(JS編)

プリプロセッサ関連

SCSSのインストール

% npm i -D node-sass sass-loader

いつでも変更可能です。

通常はSCSSを利用します。インデント記法のstylusやsassも好きですが、学習コストを抑えるため特に理由がなければSCSSを選択します。

Tips:コーダーの希望があれば、PostCSS(デフォルト)の利用を検討します。

SCSSの変数・mixinを共通で使えるようにする

% npm i -D @nuxtjs/style-resources
nuxt.config.js
export default {
  ...
  buildModules: [
    '@nuxtjs/style-resources',
  ],
  styleResources: {
    scss: [
      '~assets/variables.scss'
      '~~root-variables.scss'
    ]
  },
}

いつでも変更可能です。

通常は@nuxtjs/style-resourcesを利用します。毎回インポートせずに変数やmixinをページに挿入する必要がある場合に便利です。

Tips:公式ドキュメントの警告に従い、@nuxtjs/style-resourcesを利用します。

Tips:@nuxtjs/style-resourcesは、エイリアス(~~~)が利用可能です。変更に強くするためエイリアスを利用してください。

eslint・stylelint関連

eslintの設定

特に必要はありません(cool!)。

Tips:npm run lint:js -- --fixで、ルールによって報告された違反を自動的に修正します。

Tips:独自ルールを追加したい場合は、公式ドキュメントを参考に.eslintrc.jsのroulesへ記載します。

↓ 個人的に好きなroulesの設定

eslintrc.js
module.exports = {
  ...
  // add your custom rules here
  rules: {
    'comma-dangle': ['error', 'only-multiline'], // 末尾のセミコロンを許容する。
    'no-multiple-empty-lines': ['warn', { max: 1 }] // 空白行に対してwarnのみ出るようにする。
  },
}

stylelintの設定

% npm i -D stylelint-scss stylelint-config-standard-scss stylelint-config-recess-order
stylelint.config.js
module.exports = {
  extends: [
    'stylelint-config-standard-scss',
    'stylelint-config-recess-order'
  ],
  // add your custom config here
  // https://stylelint.io/user-guide/configuration
  rules: {}
}
package.json
{
  ...
  "scripts": {
    ...
    "lint:style": "stylelint **/*.{vue,css,scss} --ignore-path .gitignore", // {vue,css}にscssを追加
    ...
  },
  "lint-staged": {
    ...
    "*.{css,vue,scss}": "stylelint" // {vue,css}にscssを追加
  },
  ...
}

いつでも変更可能です。

stylelintをSCSSへ対応させるためにstylelint-scssをインストールします。基本的なSCSSルールとしてstylelint-config-standard-scssを、並び順指定として'stylelint-config-recess-order'を利用します。

また、lintできるようにpackage.jsonにscss追加します。

Tips:npm run lint:style -- --fixで、ルールによって報告された違反を自動的に修正します。

Tips:独自ルールは公式ドキュメントを参考にroulesへ記載します。

↓ 個人的に好きなroulesの設定

stylelint.config.js
module.exports = {
  ...
  // add your custom config here
  // https://stylelint.io/user-guide/configuration
  rules: {
    'block-no-empty': null, // 空のブロックの指定をしない
    'font-family-no-missing-generic-family-keyword': null, // font-familyに関する指定をしない
    'no-descending-specificity': null, // 詳細度並び順の指定をしない
    'comment-empty-line-before': null, // コメント前へ改行を入れる指定をしない
    'at-rule-empty-line-before': null, // @前へ改行をいれる指定をしない
    'declaration-empty-line-before': null, // @後や--後へ改行をいれる指定をしない
    'selector-type-no-unknown': [ // セレクタータイプチェックの除外指定
      true, // 基本はチェックする
      { ignoreTypes: ['_', 'x'] } // チェック除外項目
    ]
  }
}

参考:Nuxt.js(v2)でstyleLintの設定をする。ついでにVScodeで自動整形させる。

pugのインストール

% npm i pug@2.0.3 pug-plain-loader

pug好きです。名前かわいいです。最近、出番が少なくて可哀想です。

VScode関連

VScodeの機能拡張をインストール・設定をします。本項では最低限入れておきたい4つをビックアップしました。VScodeの開発スピードは速いため仕様がよく変わります。検索で出てくる記事の更新日に注意してください。

また、自動保存等のVScodeのセッティングを外部ファイル化して共有することができます。本項の最後に記載しています。

Tips:エディターは好みでいいと考えますが、lintErrorがあるとcommitができません。commit前にnpm run lint:js -- --fix npm run lint:style -- --fixを実行してください。

機能拡張「Vetur」

Veturにアクセスしてinstallボタンを押して…詳細は割愛します…インストールします。vueファイルのシンタックスハイライトに必要です。

特に機能拡張の設定は必要ありません(cool!)。

機能拡張「editorconfig for VS code」

editorconfig for VS codeをインストールします。新規ファイル生成時などに.editerconfigファイルの設定が反映されます。

特に機能拡張の設定は必要ありません。

機能拡張「eslint」

eslintをインストールします。.eslintrc.jsファイルの設定が反映されます。また、自動保存機能を有効にするためには、settings.jsonの書き換えが必要です。

機能拡張「stylelint」

stylelintは公式にリリースしたstylelintをインストールします。同じ名前の機能拡張にstylelintがありますが、公式じゃないので注意してください。

また、自動保存機能を有効にするためには、settings.jsonの書き換えが必要です。

保存時にlintで自動整形するためのセッティング

.vscode/settings.json
{
  // ↓自動フォーマット関連
  "editor.formatOnPaste": true, // ペースト時自動フォーマット
  "editor.formatOnType": true, // 入力時自動フォーマット
  "editor.formatOnSave": true, // 保存時自動フォーマット
  // ↓自動フォーマット(html)
  "html.format.endWithNewline": true, // htmlの末尾に改行を入れる
  "html.format.wrapLineLength": 0, // htmlファイルで一行あたりの文字数制限を解除
  "html.format.preserveNewLines": true, // 要素の前の改行を保持する
  "html.format.maxPreserveNewLines": 1, // 保持できる最大の改行数
  "html.format.wrapAttributes": "auto", // タグ内の属性を折り返さない
  // ↓自動フォーマット(eslint)
  "eslint.alwaysShowStatus": true, // 常にeslintの状態を表示する
  "eslint.format.enable": true, // eslintの自動フォーマット
  // ↓自動フォーマット(stylelint)
  "editor.codeActionsOnSave": {
    "source.fixAll.stylelint": true
  }
}
.vscode/extensions.json
{
  // このワークスペースのユーザーに推奨される拡張機能のリストです。
  "recommendations": [
    "octref.vetur",
    "editorconfig.editorconfig",
    "dbaeumer.vscode-eslint",
    "stylelint.vscode-stylelint"
  ],
  // このワークスペースのユーザーに推奨していない拡張機能のリストです。
  "unwantedRecommendations": [
    "esbenp.prettier-vscode"
  ]
}

.vscodeディレクトリに、各種設定を書いたsettings.jsonを、また、必要な機能拡張を書いたextensions.jsonをおくことで、プロジェクト毎に共有することができます。

設定内容は上記コメントを参照してください。

ビルド速度を上げる方法3種

後から変更可能です。

Nuxt.jsのビルドを早くするためには、parallelcachehardsourceの3つの方法がります。通常は、ビルドの遅延が我慢できなくなった段階でどれか1つを導入します。
全て「実験的機能」なので自己責任で活用します。

Tips:遅延の原因は色々です。プロジェクトに合わせて選んでください。

nuxt.config.js
module.exports = {
  build: {
    parallel: true,
    cache: true,
    hardsource: true,
  }
  ...

generateのために行う設定や注意点

やっと本題です。忘れがちな設置をはじめ、SEO周りの注意点を記載します。全て、後から変更可能です。

404エラーページ

layouts/error.vue
 <template>
  <div class="container">
    <h1 v-if="error.statusCode === 404">
      音速で探しましたがページが見つかりません
    </h1>
    <h1 v-else>
      エラーが発生しました
    </h1>
    <nuxt-link to="/">
      ホーム
    </nuxt-link>
  </div>
</template>

<script>
export default {
  props: {
    error: {
      type: Object,
      default: null
    }
  }
}
</script>
nuxt.config.js
export default {
  generate: {
    fallback: true
  }
}
static/.htaccess
<IfModule mod_rewrite.c>

# RewriteEngineをOn、ドキュメントルートを設定する ---
RewriteEngine on
RewriteBase /

# 404リダイレクト ---
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.*$ 404.html

</IfModule>

公式ドキュメントを参考にlayoutsディレクトリへerrorページを作成します。次に、generateプロパティに{ fallback: true }を設定することで、generate時にerrorページを404.htmlファイルとして生成します。また、Apacheサーバーのために.htaccessを準備しておきます。

Tips:デフォルトでは200.htmlファイルが生成されます。

Tips:.htaccessはmod_rewriteで記述しています。サーバー仕様に合わせて変更してください。サーバーの管理画面から変更できる場合はそちらも検討してください。


個人的によく使う.htaccessの記述
src/static/.htaccess
<IfModule mod_rewrite.c>

# --- RewriteEngineをOn、ドキュメントルートを設定する ---
RewriteEngine on
RewriteBase /

# --- index.html/phpなしに統一 ---

RewriteCond %{THE_REQUEST} ^.*/index.html
# ↓特定のディレクトリを対象外にする
# RewriteCond %{REQUEST_URI} !(xxx/)
RewriteRule ^(.*)index.html$ $1 [R=301,L]

RewriteCond %{THE_REQUEST} ^.*/index.php
# ↓特定のディレクトリを対象外にする
# RewriteCond %{REQUEST_URI} !(xx/)
RewriteRule ^(.*)index.php$ $1 [R=301,L]

# --- wwwあり・なしに統一 ---

# あり
# RewriteCond %{HTTP_HOST} ^example\.com$
# RewriteRule ^(.*)$ https://www.example.com/$1 [R=301,L]

# なし
RewriteCond %{HTTP_HOST} ^www\.example\.com$
RewriteRule ^(.*)$ https://example.com/$1 [R=301,L]

# --- httpsに統一 ---

RewriteCond %{SERVER_PORT} 80
# ↓特定のディレクトリを対象外にする
# RewriteCond %{REQUEST_URI} !(xxx/)
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]

# --- ディレクトリダイレクト ---

RewriteRule ^oldDir/(.*)$ newDir/$1 [R=301,L]

# --- ページリダイレクト ---

RewriteRule ^oldDir/index.html newDir/new.html [R=301,L]

# --- 404リダイレクト ---

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.*$ 404.html

</IfModule>


# キャッシュコントロール
# Thanks:https://html5boilerplate.com/

<IfModule mod_expires.c>

    ExpiresActive on
    ExpiresDefault                                      "access plus 1 month"

  # CSS
   ExpiresByType text/css                              "access plus 1 year"

  # Data interchange
    ExpiresByType application/json                      "access plus 0 seconds"
    ExpiresByType application/xml                       "access plus 0 seconds"
    ExpiresByType text/xml                              "access plus 0 seconds"

  # Favicon (cannot be renamed!)
    ExpiresByType image/x-icon                          "access plus 1 week"

  # HTML components (HTCs)
    ExpiresByType text/x-component                      "access plus 1 month"

  # HTML
    ExpiresByType text/html                             "access plus 0 seconds"

  # JavaScript
    ExpiresByType application/javascript                "access plus 1 year"

  # Manifest files
    ExpiresByType application/x-web-app-manifest+json   "access plus 0 seconds"
    ExpiresByType text/cache-manifest                   "access plus 0 seconds"

  # Media
    ExpiresByType audio/ogg                             "access plus 1 month"
    ExpiresByType image/gif                             "access plus 1 month"
    ExpiresByType image/jpeg                            "access plus 1 month"
    ExpiresByType image/png                             "access plus 1 month"
    ExpiresByType video/mp4                             "access plus 1 month"
    ExpiresByType video/ogg                             "access plus 1 month"
    ExpiresByType video/webm                            "access plus 1 month"

  # Web feeds
    ExpiresByType application/atom+xml                  "access plus 1 hour"
    ExpiresByType application/rss+xml                   "access plus 1 hour"

  # Web fonts
    ExpiresByType application/font-woff                 "access plus 1 month"
    ExpiresByType application/vnd.ms-fontobject         "access plus 1 month"
    ExpiresByType application/x-font-ttf                "access plus 1 month"
    ExpiresByType font/opentype                         "access plus 1 month"
    ExpiresByType image/svg+xml                         "access plus 1 month"

</IfModule>


sitemap.xml

% npm i @nuxtjs/sitemap
nuxt.config.js
module.exports = {
  modules: [
    ...
    // Doc: https://github.com/nuxt-community/sitemap-module
    // ↓ 配列の最後でsitemapモジュールを宣言
    '@nuxtjs/sitemap'
  ],
  sitemap: {
    hostname: 'https://example.com',
    // ↓ 除外ディレクトリやページを指定
    exclude: [
      '/admin/**',
       ...
    ],
    // ↓ 動的なルーティングで生成したページは明示的に宣言
    routes: [
      '/kind/apple',
      '/kind/banana',
       ...
     ]
  }
}

Nuxt.jsの公式モジュールを利用してsitemapXMLを生成します。ASPサービスなどの一般的なsitemapジェネレーターではページを取得できません。また、動的なルーティングで生成したページは、明示的に宣言をする必要があります。

Tips:sitemapモジュールは宣言の順番に指定があります。注意してください。

他のモジュール(例:nuxt-i18n)を使用する場合は、常に配列の最後でsitemapモジュールを宣言して
ください。modules: ['nuxt-i18n', '@nuxtjs/sitemap']

Tips:routesプロパティは関数も利用できます。

GTMの設置

npm i @nuxtjs/gtm
nuxt.config.js
module.exports = {
  modules: [
    // Docs: https://github.com/nuxt-community/gtm-module
    '@nuxtjs/gtm',
    ...
  ],
  gtm: {
    id: 'GTM-XXXXXXX',
    pageTracking: true,
    enabled: true, // 常に(npm run devの場合も)GTMイベントを送信
  },
}

Nuxt.jsの公式モジュールを利用してタグの実行やpageTrackingを送信します。pageTracking(nuxtRoute)を受け取りページビューを計測するためには、GTM側でカスタムトリガーを設定する必要があります。GTM発行者と事前に打ち合わせをしてください。

Tips:googleアナリティクスを別途入れている場合は、二重イベントの発生に注意してください。発生時はpageTracking: trueを削除してください。デフォルトはfalseです。

Tips:URLの末尾に/(スラッシュ)を強制的したい場合は、routerプロパティにtrailingSlash: trueを追加します(注意:Nuxt.js v2.10.0以降)。注意事項を読んでから利用してください。

nuxt.config.js
export default {
  router: {
    trailingSlash: true,
    ...
  },
...

Tips:ページタイトルなど諸情報の取得したい場合は、自作のプラグインを検討してください。

自作のGTMプラグイン(サンプル)

注意:Nuxt.jsの公式モジュールが、@nuxtjs/google-tag-managerが@nuxtjs/gtmへ統合されました。イベントの送信がthis.$gtm.push({ event: 'myEvent', ...someAttributes })でできるため、自作する必要が無くなったかもしれません。(検証中)


自作のGTMプラグイン(サンプル)
src/plugins/gtm.client.js
export default ({ app }) => {
  // gtmのID
  const gtmId = 'GTM-XXXX'

  if (process.env.NODE_ENV !== 'production') { return }

  /* eslint-disable */
  (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer',gtmId);
  /* eslint-enable */

  // ↓dataLayerに渡したい情報を設定
  /* eslint-disable no-undef */
  app.router.afterEach((to, from) => {
    setTimeout(() => {
      dataLayer.push({
        event: 'loadReady',
        trackPageTitle: document.title,
        trackPageView: to.fullPath,
        path: location.pathname,
        url: location.href
      })
      // ↓GTMへ渡すデーターの確認
      console.log(dataLayer)
    }, 500)
  })
  /* eslint-enable no-undef */
}
module.export = {
  plugins: [
    '~/plugins/gtm.client'
  ],
  ...
}


いやいや、うちはGAだから…

GA用のドキュメントの参照してください。

SEO・OGP・icon・PWA

nuxt.config.jsで、SEO関連・OGP関連・icon関連・PWA関連の設定をします。各ページでの変更方法は公式ドキュメントを参照してください。


すべてを設定したサンプル
nuxt.config.js
// path
const baseHost = process.env.BASE_HOST || 'http://localhost:3000'
const baseDir = process.env.BASE_DIR || '/'
const baseUrl = baseHost + baseDir

// meta
const lang = 'ja'
const siteName = '株式会社サイト名ロング'
const siteDesc = '共通のディスクリプション。'
const siteKeywords = '1つ目,2つ目,3つ目,4つ目'

// images
const iconImages = baseDir + 'img/icons/'
const ogpImages = baseUrl + 'img/ogp/'

// pwa
const shortName = 'サイト名ショート'
const manifestIcon = baseDir + 'img/icons/icon-1024.png'
const splashScreens = baseDir + 'img/splash-screens/'

export default {
  ...
  router: {
    base: baseDir
  },
  // Doc: http://ogp.me/
  head: {
    htmlAttrs: {
      prefix: 'og: http://ogp.me/ns#',
      lang
    },
    titleTemplate: `%s - ${siteName}`,
    meta: [
      // 設定関連
      { charset: 'utf-8' },
      { 'http-equiv': 'x-ua-compatible', content: 'ie=edge' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { name: 'format-detection', content: 'telephone=no, email=no, address=no' },

      // SEO関連
      { hid: 'description', name: 'description', content: siteDesc },
      { hid: 'keywords', name: 'keywords', content: siteKeywords },

      // OGP関連
      { hid: 'og:site_name', property: 'og:site_name', content: siteName },
      { hid: 'og:type', property: 'og:type', content: 'website' },
      { hid: 'og:url', property: 'og:url', content: baseUrl },
      { hid: 'og:title', property: 'og:title', content: siteName },
      { hid: 'og:description', property: 'og:description', content: siteDesc },
      { hid: 'og:image', property: 'og:image', content: `${ogpImages}home.jpg` },
      { name: 'twitter:card', content: 'summary_large_image' },
      // { name: 'twitter:site', content: '@Twitter' },
      // { property: 'article:publisher', content: 'FacebookURL' },
      // { property: 'fb:app_id', content: 'FacebookAppID' },
    ],
    link: [
      // favicon
      { rel: 'icon', type: 'image/x-icon', href: 'favicon.ico' },
      { rel: 'apple-touch-icon', sizes: '180x180', href: 'apple-touch-icon.png' },

      // pwa splash screens
      // Doc: https://appsco.pe/developer/splash-screens
      { href: 'iphone5_splash.png', media: '(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' },
      { href: 'iphone6_splash.png', media: '(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' },
      { href: 'iphoneplus_splash.png', media: '(device-width: 621px) and (device-height: 1104px) and (-webkit-device-pixel-ratio: 3)', rel: 'apple-touch-startup-image' },
      { href: 'iphonex_splash.png', media: '(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)', rel: 'apple-touch-startup-image' },
      { href: 'iphonexr_splash.png', media: '(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' },
      { href: 'iphonexsmax_splash.png', media: '(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)', rel: 'apple-touch-startup-image' },
      { href: 'ipad_splash.png', media: '(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' },
      { href: 'ipadpro1_splash.png', media: '(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' },
      { href: 'ipadpro3_splash.png', media: '(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' },
      { href: 'ipadpro2_splash.png', media: '(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' }
    ],
    script: [
      { src: '//polyfill.io/v3/polyfill.min.js?features=es2015%2CWebAnimations%2CIntersectionObserver' }
    ]
  },
  /*
  ** Nuxt.js modules
  */
  modules: [
    '@nuxtjs/pwa',
    ...
  ],
  pwa: {
    icon: {},
    meta: {
      // mobileAppIOSオプションを有効にする前に、以下記事を一読すること。
      // https://medium.com/@firt/dont-use-ios-web-app-meta-tag-irresponsibly-in-your-progressive-web-apps-85d70f4438cb
      // mobileAppIOS: true
    },
    manifest: {
      lang,
      name: siteName,
      short_name: shortName,
      description: siteDesc,
      background_color: '#ffffff',
      theme_color: '#ffffff',
      display: 'standalone',
      orientation: 'portrait'
    },
    workbox: {
      runtimeCaching: [
        {
          urlPattern: 'https://polyfill.io/.*',
          handler: 'cacheFirst'
        },
        {
          urlPattern: '^https://fonts.(?:googleapis|gstatic).com/(.*)',
          handler: 'cacheFirst'
        },
        {
          urlPattern: 'https://cdn.jsdelivr.net/.*',
          handler: 'cacheFirst'
        },
        {
          urlPattern: baseDir + '.*',
          handler: 'staleWhileRevalidate',
          strategyOptions: {
            cacheName: 'my-cache',
            cacheExpiration: {
              maxAgeSeconds: 24 * 60 * 60 * 30
            }
          }
        }
      ]
    }
  },
  ...
}


SEO関連の設定

nuxt.config.js
// meta
const lang = 'ja'
const siteName = '株式会社サイト名'
const siteDesc = '共通のディスクリプション。'
const siteKeywords = '1つ目,2つ目,3つ目,4つ目'

module.exports = {
  head: {
    htmlAttrs: {
      lang
    },
    titleTemplate: `%s - ${siteName}`,
    meta: [
      // 設定関連
      { charset: 'utf-8' },
      { 'http-equiv': 'x-ua-compatible', content: 'ie=edge' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { name: 'format-detection', content: 'telephone=no, email=no, address=no' },

      // SEO関連
      { hid: 'description', name: 'description', content: siteDesc },
      { hid: 'keywords', name: 'keywords', content: siteKeywords },

      ...
      ]
    }
  },
  ...
}

基本となるタイトルやディスクリプションを設定します。また、諸情報を変数に格納します。後述するOGPやPWAと共通の項目があるからです。

OGP関連の設定

nuxt.config.js
// path
const baseHost = process.env.BASE_HOST || 'http://localhost:3000'
const baseDir = process.env.BASE_DIR || '/'
const baseUrl = baseHost + baseDir

// meta
...
const siteName = '株式会社サイト名ロング'
const siteDesc = '共通のディスクリプション。'
...

// images
const ogpImages = baseUrl + 'img/ogp/'

export default {
  router: {
    base: baseDir
  },
  // Doc: http://ogp.me/
  head: {
    htmlAttrs: {
      prefix: 'og: http://ogp.me/ns#',
      ...
    },
    meta: [
      // OGP関連
      { hid: 'og:site_name', property: 'og:site_name', content: siteName },
      { hid: 'og:type', property: 'og:type', content: 'website' },
      { hid: 'og:url', property: 'og:url', content: baseUrl },
      { hid: 'og:title', property: 'og:title', content: siteName },
      { hid: 'og:description', property: 'og:description', content: siteDesc },
      { hid: 'og:image', property: 'og:image', content: `${ogpImages}home.jpg` },
      { name: 'twitter:card', content: 'summary_large_image' },
      // { name: 'twitter:site', content: '@Twitter' },
      // { property: 'article:publisher', content: 'FacebookURL' },
      // { property: 'fb:app_id', content: 'FacebookAppID' },

    ],
  ...

The Open Graph protocolを参考にOGP関連を設定します。ogp画像は各ページで個別に設定する可能性があるため、static/img/ogp/内に格納してください。また、og:urlog:imageは絶対URLが推奨されるため、hostをnode環境変数から変更できるようにしています。

Tips:generate方法は以下となります。npm-scriptgenerate:production generate:stagingなどを記載し、以下記述を追記しておくと便利です。

例)https://example.com/yourDir/ にアップしたい場合

MAC
↓
% BASE_HOST=https://example.com BASE_DIR=/yourDir/ npm run generate

WINDOWS
↓
% npx cross-env BASE_HOST=https://example.com BASE_DIR=/yourDir/ nuxt generate

favicon.ico

各種ブラウザ用のファビコンfavicon.iconと、iOSショートカットリンク画像apple-touch-icon.pngを作成し、staticディレクトリ直下へ設置します。

1) favicon.icon用画像(256x256)を透過.pngで作成し、Favicon ジェネレーターなどで、マルチファビコンを作成すると便利です。

2)iOSショートカットリンク画像(180x180)を作成。

nuxt.config.js
export default {
  head: {
    link: [
      // favicon
      { rel: 'icon', type: 'image/x-icon', href: 'favicon.ico' },
      { rel: 'apple-touch-icon', sizes: '180x180', href: 'apple-touch-icon.png' },
      ...
    ]
  },

PWA関連

nuxt.config.js
// path
...
const baseDir = process.env.BASE_DIR || '/'
...

// meta
const lang = 'ja'
const siteName = '株式会社サイト名ロング'
const siteDesc = '共通のディスクリプション。'
...

// pwa
const shortName = 'サイト名ショート'

export default {
  head: {
    ...
    script: [
      // polyfills ※キャッシュ例のために記載
      { src: '//polyfill.io/v2/polyfill.min.js?features=WebAnimations,IntersectionObserver' }
    ],
    link: [
      // favicon
      { rel: 'icon', type: 'image/x-icon', href: 'favicon.ico' },
      { rel: 'apple-touch-icon', sizes: '180x180', href: 'apple-touch-icon.png' },

      // fonts ※キャッシュ例のために記載
      { rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' },
      { rel: 'stylesheet', href: 'https://cdn.jsdelivr.net/npm/yakuhanjp@3.3.1/dist/css/yakuhanmp.min.css', crossorigin: 'anonymous' },
      { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css?family=Noto+Serif+JP&display=swap' },
      { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap' },

      // pwa splash screens
      // Doc: https://appsco.pe/developer/splash-screens
      { href: 'iphone5_splash.png', media: '(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' },
      { href: 'iphone6_splash.png', media: '(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' },
      { href: 'iphoneplus_splash.png', media: '(device-width: 621px) and (device-height: 1104px) and (-webkit-device-pixel-ratio: 3)', rel: 'apple-touch-startup-image' },
      { href: 'iphonex_splash.png', media: '(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)', rel: 'apple-touch-startup-image' },
      { href: 'iphonexr_splash.png', media: '(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' },
      { href: 'iphonexsmax_splash.png', media: '(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)', rel: 'apple-touch-startup-image' },
      { href: 'ipad_splash.png', media: '(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' },
      { href: 'ipadpro1_splash.png', media: '(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' },
      { href: 'ipadpro3_splash.png', media: '(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' },
      { href: 'ipadpro2_splash.png', media: '(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)', rel: 'apple-touch-startup-image' }
    ]
  },
  pwa: {
    icon: {},
    meta: {
      // mobileAppIOSオプションを有効にする前に、以下記事を一読すること。
      // https://medium.com/@firt/dont-use-ios-web-app-meta-tag-irresponsibly-in-your-progressive-web-apps-85d70f4438cb
      // mobileAppIOS: true
    },
    manifest: {
      lang,
      name: siteName,
      short_name: shortName,
      description: siteDesc,
      background_color: '#ffffff',
      theme_color: '#ffffff',
      display: 'standalone',
      orientation: 'portrait'
    },
    workbox: {
      runtimeCaching: [
        {
          urlPattern: 'https://polyfill.io/.*',
          handler: 'cacheFirst'
        },
        {
          urlPattern: '^https://fonts.(?:googleapis|gstatic).com/(.*)',
          handler: 'cacheFirst'
        },
        {
          urlPattern: 'https://cdn.jsdelivr.net/.*',
          handler: 'cacheFirst'
        },
        {
          urlPattern: baseDir + '.*',
          handler: 'staleWhileRevalidate',
          strategyOptions: {
            cacheName: 'my-cache',
            cacheExpiration: {
              maxAgeSeconds: 24 * 60 * 60 * 30
            }
          }
        }
      ]
    }
  },

PWAは1つの技術を示す物ではなくウェブアプリのジャンルです。googleから良記事が出ていますのでPWAの特徴を把握してください。

本項では「headプロパティ」へ、iOS関連の設定及びスプラッシュスクリーンの設定を行い、「pwaプロパティ」へ@nuxtjs/pwaモジュールに準じた設定を行います。また、本項では通知機能は記載ありません。@nuxtjs/pwaの公式ドキュメント内のOneSignalを確認・設定してください。

以下にポイントのみ記載します。

スプラッシュスクリーン画像及びPWA用icon画像の作成

スプラッシュスクリーン画像は、自作するかASPサービス等を利用して事前に生成します。PWA用icon画像は、PWAモジュールのicon機能を利用して自動生成しますので「1024×1024」の画像を作成し、static/img/icons/ディレクトリへ格納しgenreate時に生成します。

Tips:キャッシュの影響で古いicon.pngが出力されることがあります。node_modules/.cache/pwa/iconディレクトリを削除の上、再度テストしてください。

Workboxのキャッシュ戦略

PWAモジュールのWorkboxを項を参照してください。何も設定しなくても、@nuxtjs/pwaモジュールが問題のない範囲でよしなにやってくれます(cool!)。本項では、runtimeCachingを利用して、追加でキャッシュさせる項目を指定しています。

PWAのデバッグ方法

chromeで調査、変更、デバッグできます。詳しくはgoogleの良記事を確認してください。

差分納品を求められた!!

nuxt.config.js
module.exports = {
  build: {
    filenames: {
      app: () => '[name].js',
      chunk: () => '[name].js',
      css: () => '[name].js',
      img: () => '[path][name].[ext]',
      font: () => '[path][name].[ext]',
      video: () => '[path][name].[ext]'
    },
    ...
  }
}

公式ドキュメントの反対の方法を実施します。お勧めめできませんが、上様には逆らえない事情もあります。キャッシュ関連の問題が起こる可能性を伝えた上で導入してください。

Tips:macとWindowsで出力形式が違います。注意してください。

参考:nuxt 2 で静的ファイルの生成時にファイル名を固定したい。


おまけ1

おまけ2

ご指摘お待ちしております。

もともと本項は講義用のメモでしかも情報が古いしちょっと間違ってる…なので消そうとしましたが、ある程度いいねがついてしまったので書き直しました。頭の整理にもなり、また気分的にもスッキリです。いいねと背中を押してくれた皆様ありがとうございます。

また、テキスト量を減らすため言い切り型の言い回しが多いですが、プロジェクトに合わせて柔軟に対応してもらえると嬉しいです。説明にはできるだけ公式ドキュメントのリンクを貼っていますので参考にしてください。

個人的な考えですが…。

Nuxt.jsはコーダーがフロントエンドエンジニアになるための、ベストプラクティスを集めたナイスなフレームワークだと感じています。ちょっとしたLP作成から最近流行りのSTUDIOなど幅広い場所で活躍しています(cool!)。

Nuxt.jsをきっかけにesLintやstyleLintを使い始めたメンバーも多く見受けられます。create-nuxt-appの変化スピードは追っかけてる身としては少し辛い(褒めている)ですが、プロジェクトをこなすたびに自然と時代の流れに乗ることができる点も評価が高いです。

Nuxt.jsに熱中してたら、Node.jsのサーバーサイド関連も…なんてことも。間も無くVue3がリリースされるので、もしかしたらNuxt.jsのv2関連の記事もこれが最後かもしれませんが、他記事もちょいちょい修正していこうと思います。

それにしても…。

もっとシンプルに生きたい。

Link

以下、公開中のNuxt.js(v2)関連の記事一覧

古い記事がありますので注意してください。Nuxt.js(v3)が出るまでは、ちょいちょい直していく予定です。

提案系

  1. Nuxt.jsにおける「components」ディレクトリの規約(案)

技術より記事

  1. Nuxt.js(v2)のインストール〜ESLint設定まで
  2. Nuxt.js(v2)の作業ディレクトリを整理
  3. Nuxt.js(v2)のベースURLをターミナルからコントロール
  4. Nuxt.js(v2)でpug/stylusを利用する
  5. Nuxt.js(v2)でIE11対応をする(CSS編)
  6. Nuxt.js(v2)でIE11対応をする(JS編)
  7. Nuxt.js(v2)で絶対パス(https~)を取得する方法
  8. Nuxt.js(v2)でSEOに必要なmeta(OGP)を入れたい
  9. Nuxt.js(v2)でSEOに必要なmeta(OGP)で入力漏れの事故をなくす

よく使うプラグインのお話

  1. Nuxt.js(v2)で便利なvue-mqを使ってみるがSSRモードでコンソールエラーがでるので確認してみた。
  2. nuxt-linkでスムーズスクロールするならvue-scrolltoが便利で気が利いている…。
  3. Nuxt.jsでパララックスをするならvue-parallax-jsがお手軽。Cool!
342
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
amishiro
5年近く作っていた自作テンプレートから離れて、最近はnuxtで開発してる。フロントエンドの未来は明るい。寂しいのでフォロープリーズ。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
342
Help us understand the problem. What is going on with this article?