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,
  env: {
    browser: true,
    node: 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 にはなかった pluginsrules の設定が追加されているのが確認できると思います。

設定ファイルを拡張する 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',
  '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 .

コミット時

huskypre-commit hook に lint-staged を設定することで、変更したファイルのみを ESLint の対象にする。

ファイル保存時

VSCode を利用している場合

{ 
  "eslint.autoFixOnSave": true,
  "eslint.validate": [
    {
      "language": "vue",
      "autoFix": true
    },
    {
      "language": "typescript",
      "autoFix": true
    },
    {
      "language": "javascript",
      "autoFix": true
    }
  ],
  "editor.formatOnSave": false
}

保存時に ESLint を実行させます。必要に応じて "editor.formatOnSave": false, のように既存処理が邪魔をしない設定も追加してください。

Date と Timezone

ちょっとしたメモです。でてくるサンプルコードは JavaScript です。

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"
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.jspages/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: ['@@'],
};

参考

コンポーネント単体テストについてつらつらと

書きます。

何をテストするのか

f:id:ryotah:20181203134621p:plain

コンポーネント単体テストで何をテストするのか。

基本的に、考え方は関数のテストと同じだと思います。何かが入力されると結果が出力される。テストでは、その結果が正しいかを確認します。「入力」と「出力」にフォーカスすることが大切です。

一般的なヒント | Vue Test Utils では以下のように説明しています。

コンポーネントのパブリックインターフェイスを検証するテストを作成し、内部をブラックボックスとして扱うことをお勧めします。単一のテストケースでは、コンポーネントに提供された入力(ユーザーのやり取りやプロパティの変更)によって、期待される出力(結果の描画またはカスタムイベントの出力)が行われることが示されます。

このように考えれば、テストの方針も立てやすくなります。結果、何をテストしているのか理解しやすくなりますし、コードもシンプルになります。内部実装をリファクタしたとしても、テストが壊れることはないです。

なぜ書くのか

なぜテストをするのですか | Vue Test Utils から引用。

単体テストの利点:

  • コンポーネントがどう動作すべきかのドキュメントを提供します
  • 過度な手動テストの時間を節約します
  • 新しい機能におけるバグを減らします
  • 設計を改良します
  • リファクタリングを容易にします

(ここに「フレームワークへの理解が深まる」も追加できると思います。)

単体テストのすべきこと:

  • 実行が早いこと
  • 理解しやすいこと
  • 一つの作業だけをテストすること

テストが書きにくいな思ったら、もしかしたら、コンポーネントの設計に問題があるかもしれません。例えば、あるコンポーネントに複数の役割を持たせていたり、必要以上に大きく依存関係が複雑になってたり。

テストを書くことで既存コードに改良の余地があることに気づく、そういったケースもあるかと思います。

入力と出力とは何か

f:id:ryotah:20181203134642p:plain

具体的に「入力」と「出力」にはどのようなものがあるのでしょうか。一般的な SPA の Web アプリケーションを例にして考えてみます。

入力:

  • プロパティ(親コンポーネントから渡される)
    • Vue なら props, Angular なら @Input に相当するもの
  • DOM
    • ユーザーによる UI 操作により発生
    • クリックアクションやフォームの入力など
  • Route
    • URL が変更することにより発生
    • Vue なら this.$route, Angular なら ActivatedRoute
  • Store
    • Flux, Redux のような状態管理を導入している場合、Store の変更もコンポーネントへの入力となる
    • Vue (Vuex) なら this.$store.state (あるいは this.$store.getters), Angular (ngrx) なら this.store.pipe(select(...) など

出力:

  • イベント(親コンポーネントへ渡す)
    • Vue なら emit, Angular なら @Output に相当するもの
  • DOM
    • 表示されたテキスト, 要素の On/Off, スタイル, 属性 など
  • Route
    • Router へのアクション
  • Store
    • Vue (Vuex) なら this.$store.{dispatch|commit}, Angular (ngrx) なら this.store.dispatch など

これ以外にもあるとは思いますが、大体このようなものではないでしょうか。

テストを書く前に調べること

初めてさわるフレームワークコンポーネント単体テストを書く場合、上にあげたような「入力」をシュミレートする方法と「出力」を検証する方法を知っておくと、順調にテストが書けると思います。

あとは非同期の扱いや、テストダブル(スタブ・モック・スパイなど)の扱いについて知っておくといいかと。(これらに関しては、利用するテストフレームワーク側の機能を利用することも多いです。)

一般的なコンポーネントのテストシナリオ

Angular ドキュメントには Component Test Scenarios というセクションがあり、一般的なテストシナリオの実装方法について解説しています。「一般的なテストシナリオにはどんなものがあるのか」について知るという点で、Angular ユーザー以外にも参考になると思います。

以下は抜粋です。

Vue 単体テストのサンプル

テストシナリオを踏まえて、Vue の単体テストで必要そうなケースをいくつかピックアップしたのがこちらです。(若干中途半端ではありますが)

propsemit のテスト

describe("Button", () => {
  it(`should emit a 'click' event`, () => {
    const wrapper = shallowMount(Button, {
      propsData: {
        text: "text"
      }
    });
    wrapper.find("button").trigger("click");
    expect(wrapper.emitted().click).toBeTruthy();
  });
});

NuxtLink (RouterLink) をスタブします。

describe("Breadcrumbs", () => {
  it("should have nuxt-link correspond to props", () => {
    const breadcrumbs = [{ label: "Foo", path: "foo" }];
    const wrapper = shallowMount(Breadcrumbs, {
      propsData: {
        breadcrumbs
      },
      stubs: {
        NuxtLink: RouterLinkStub
      }
    });
    expect(wrapper.find(RouterLinkStub).props().to).toBe(breadcrumbs[0].path);
  });
});

Router を利用しているコンポーネントのテスト

createLocalVue を利用してグローバルの Vue クラスを汚染しないローカルの Vue クラスで VueRouter を利用します。

import { createLocalVue, shallowMount } from "@vue/test-utils";
import VueRouter from "vue-router";

describe("Menu", () => {
  it(`should have '-current' class if the path is same as the current route`, () => {
    const localVue = createLocalVue();
    localVue.use(VueRouter);
    const router = new VueRouter();
    router.push("/foo");
    const wrapper = shallowMount(Menu, {
      localVue,
      router,
      propsData: {
        menus: [
          { label: "foo", value: "/foo" },
          { label: "bar", value: "/bar" }
        ]
      },
      stubs: {
        NuxtLink: RouterLinkStub
      }
    });
    const fooLink = wrapper.findAll(RouterLinkStub).at(0);
    const barLink = wrapper.findAll(RouterLinkStub).at(1);
    expect(fooLink.classes()).toContain("-current");
    expect(barLink.classes()).not.toContain("-current");
  });
});

内部で他のコンポーネントを利用しているケース

shallowMount() を利用すると、子コンポーネントはスタブされたコンポーネントになります。

メモ:

Nuxt のページコンポーネントのテスト

Nuxt の機能と、テスト対象のコンポーネントの機能をわけて考えることが大切です。

以下のテストでは「fetch 時に適切なアクションがディスパッチされているか」と「適切な middleware が登録されているか」についてだけテストをしています。初回アクセス時に fetch が呼ばれているか、パラメータ変更時にも fetch が呼ばれるか、許可されたユーザーのみアクセスできるか、などのテストはしていません。(それは Nuxt のテストや登録されているミドルウェア自体のテストになるから)

describe("FooPage", () => {
  it("dispatches FetchFoo before rendering the page", () => {
    const wrapper = shallowMount(FooPage);
    const store = {
      dispatch: jest.fn()
    };
    const fooId = "asdf";
    const route = fooRoute(fooId);
    const context: any = {
      store,
      route
    };
    // simulate the nuxt fetch hook
    wrapper.vm.$options.fetch(context);
    expect(context.store.dispatch).toHaveBeenCalledWith(FetchFoo({ fooId }));
  });
  it(`needs 'requireAuth' middleware`, () => {
    const wrapper = shallowMount(FooPage);
    expect(wrapper.vm.$options.middleware).toContain("requireAuth");
  });
});
const fooRoute = fooId => ({ params: { fooId } });

ミューテーション単体のテスト

// from https://vuex.vuejs.org/ja/guide/testing.html
describe("mutations", () => {
  it("INCREMENT", () => {
    // ステートのモック
    const state = { count: 0 };
    // ミューテーションを適用する
    increment(state);
    // 結果を検証する
    expect(state.count).to.equal(1);
  });
});

アクション単体のテスト

アクションは外部の API を呼び出す場合が多いです。そのため、モック関数を作成したり、非同期処理をうまく扱う必要があります。

import { FetchFoo, ReceiveFoo, ResetShopStaffs } from "./types";
import { actions } from "../actions";

describe("actions", () => {
  let store;
  beforeEach(() => {
    // inject plugins
    store = {
      $api: {
        request: jest.fn()
      }
    };
  });
  it("FetchFoo request Foo and then commit ReceiveFoo", async () => {
    // fake data
    const fakeRequest = "request";
    const fakeResponse = "response";

    // mock functions
    const commit = jest.fn();
    store.$api.request.mockReturnValue(fakeResponse);

    // dispach
    const action = FetchFoo(fakeRequest);
    actions[action.type].bind(store)({ commit }, action);

    // request
    expect(store.$api.request).toHaveBeenCalledWith(fakeRequest);

    await resolvePromises();

    expect(commit).toHaveBeenCalledWith(ReceiveFoo(fakeResponse));
  });
});

export const resolvePromises = () =>
  new Promise(resolve => setImmediate(resolve));

ストアのテスト(アクション、ミューテーション など全てを含む)

// from https://vue-test-utils.vuejs.org/ja/guides/using-with-vuex.html
import { createLocalVue } from "@vue/test-utils";
import Vuex from "vuex";
import storeConfig from "./store-config";
import { cloneDeep } from "lodash";

test("increments count value when increment is commited", () => {
  const localVue = createLocalVue();
  localVue.use(Vuex);
  const store = new Vuex.Store(cloneDeep(storeConfig));
  expect(store.state.count).toBe(0);
  store.commit("increment");
  expect(store.state.count).toBe(1);
});

test("updates evenOrOdd getter when increment is commited", () => {
  const localVue = createLocalVue();
  localVue.use(Vuex);
  const store = new Vuex.Store(cloneDeep(storeConfig));
  expect(store.getters.evenOrOdd).toBe("even");
  store.commit("increment");
  expect(store.getters.evenOrOdd).toBe("odd");
});

補足

グローバルプロパティをスタブする:

const $route = { path: "http://www.example-path.com" };
const wrapper = shallowMount(Foo, {
  mocks: {
    $route
  }
});

https://vue-test-utils.vuejs.org/ja/api/options.html#mocks

まとまらない何か

テストに関係する Tips とかメモとか。

flush-promises (setImmediate) を利用して非同期を解決させる

Vue でグローバル登録されてるフィルタが存在する場合

import Vue from "vue";
import * as filters from "./util/filters";

// We would extract this to a function that would be reused by both app.js and jest-setup but,
// we didn't want to change original production code
Object.keys(filters).forEach(key => {
  Vue.filter(key, filters[key]);
});

How do we test these globally registered filters? https://github.com/agualis/vue-hackernews-2.0/blob/master/src/jest-setup.js

同年、同月、同日にテストすることを願わん

momentjs - How do I set a mock date in Jest? - Stack Overflow

Date.now = jest.fn(() => 1487076708000); //14.02.2017`

Jest のカスタムマッチャ

const myMockFn = jest.fn()...;

// The mock function was called at least once
expect(mockFunc).toBeCalled();

上記のようなカスタムマッチャは .mock プロパティを検査する方法の糖衣構文

https://jestjs.io/docs/en/mock-functions

jest.spyOn(object, methodName)

  • jest.fn と同様の関数を作成する
  • 引数に与えられた object[methodName] へのコールも実装

https://jestjs.io/docs/ja/jest-object#jestspyonobject-methodname

参考 URLs

Vue SSR 環境構築メモ

Vue SSR ガイド, vuejs/vue-hackernews-2.0 を参考に環境構築を試してみた時のメモです。

サーバサイドで Vue をコンパイルする

https://github.com/ryotah/vue-ssr/compare/step-0...step-1-render-to-string

やっていること:

  • vue-server-renderer からレンダラーを作成
  • リクエスト毎に vue インスタンスを生成
  • HTML に描画して返信

コード抜粋:

server.js

const Vue = require("vue");
const server = require("express")();
// ステップ 1: レンダラを作成
const renderer = require("vue-server-renderer").createRenderer({
  template: require("fs").readFileSync("./index.template.html", "utf-8")
});

server.get("*", (req, res) => {
  // ステップ 2: Vue インスタンスを作成
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `<div>The visited URL is: {{ url }}</div>`
  });

  // ステップ 3: Vue インスタンスを HTML に描画
  renderer
    .renderToString(app)
    .then(html => res.end(html))
    .catch(err => {
      res.status(500).end("Internal Server Error");
    });
});

server.listen(8080);

参考:

クライアントとサーバのコードをわける (Webpack)

https://github.com/ryotah/vue-ssr/compare/step-1-render-to-string...step-2-webpack

やっていること:

  • クライアントとサーバー両方をバンドルするために webpack を導入
  • entry-client.js, entry-server.js, app.js を用意
    • entry-*.jsapp.js (アプリケーションのユニバーサルエントリー) を読み込む

コード抜粋:

entry-client.js

import { createApp } from "./app";

export default context => {
  const { app } = createApp(context);
  return app;
};

server.js

// ...
const createApp = require("./dist/entry").default;
server.get("*", (req, res) => {
  // ステップ 2: Vue インスタンスを作成
  const app = createApp({ url: req.url });

  // ステップ 3: Vue インスタンスを HTML に描画
  // ...
});

server.listen(8080);

webpack.config.js

module.exports = {
  entry: "./entry-server.js",
  output: {
    filename: "entry.js",
    path: path.resolve(__dirname, "dist"),
    libraryTarget: "commonjs2"
  }
  // ...
};

参考:

f:id:ryotah:20181127023444p:plain

ルーティング

https://github.com/ryotah/vue-ssr/compare/step-2-webpack...step-3-router

やっていること:

  • vue-router を導入
  • entry-server.js にルーティングロジックを実装

コード抜粋:

entry-server.js

import { createApp } from "./app";

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router } = createApp();

    // set server-side router's location
    router.push(context.url);

    // wait until router has resolved possible async components and hooks
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();
      if (!matchedComponents.length) {
        reject({ code: 404 });
      }
      resolve(app);
    }, reject);
  });
};

参考:

プリフェッチを実現

https://github.com/ryotah/vue-ssr/compare/step-3-router...step-4-store

やっていること:

  • サーバサイドとクライアントサイドで同じデータを利用可能にするために Vuex を導入
  • ルートコンポーネントにプリフェッチ用メソッド asyncData を実装
  • entry-client.js にサーバからのデータを受け取る store.replaceState(window.__INITIAL_STATE__) を追加

コード抜粋:

entry-server.js

// ...
router.onReady(() => {
  const matchedComponents = router.getMatchedComponents();
  // ...

  // call `asyncData()` on all matched route components
  Promise.all(
    matchedComponents.map(
      ({ asyncData }) =>
        asyncData &&
        asyncData({
          store,
          route: router.currentRoute
        })
    )
  )
    .then(() => {
      // すべてのプリフェッチのフックが解決されると、ストアには、
      // アプリケーションを描画するために必要とされる状態が入っています。
      // 状態を context に付随させ、`template` オプションがレンダラに利用されると、
      // 状態は自動的にシリアライズされ、HTML 内に `window.__INITIAL_STATE__` として埋め込まれます
      context.state = store.state;
      resolve(app);
    })
    .catch(reject);
}, reject);
// ...

参考:

サーバとクライアント、それぞれをビルドするために個別の Webpack 設定ファイルを用意

https://github.com/ryotah/vue-ssr/compare/step-4-store...step-5-hydration

バンドルレンダラ

https://github.com/ryotah/vue-ssr/compare/step-5-hydration...step-6-bundle-renderer

やっていること:

  • バンドルレンダラを利用
  • JSON 形式でサーバサイドのコードをバンドル

参考:

クライアントマニフェストを生成

https://github.com/ryotah/vue-ssr/compare/step-6-bundle-renderer...step-7-client-manifest

参考:

ビルド設定をもうちょい

https://github.com/ryotah/vue-ssr/compare/step-7-client-manifest...step-8-build

やっていること:

  • webpack-dev-middleware を導入
  • コードに変更があったら、サーバとクライアント両方をビルドするように設定

コード抜粋:

server.js

const path = require('path');
const express = require('express');
const MemoryFS = require('memory-fs');
const app = express();
const webpack = require('webpack');

let clientManifest;
let serverBundle;
let renderer;

function update() {
  if (!clientManifest || !serverBundle) {
    return;
  }
  // create a renderer
  const { createBundleRenderer } = require('vue-server-renderer');
  const template = require('fs').readFileSync('./index.template.html', 'utf-8');
  renderer = createBundleRenderer(serverBundle, {
    runInNewContext: false, // recommended
    template,
    clientManifest,
  });
}

// dev middleware
const clientConfig = require('./build/webpack.client.config.js');
const clientCompiler = webpack({
  ...clientConfig,
  // TODO:
  mode: 'development',
});
const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
  publicPath: clientConfig.output.publicPath,
});
app.use(devMiddleware);
clientCompiler.plugin('done', stats => {
  try {
    clientManifest = JSON.parse(
      devMiddleware.fileSystem.readFileSync(
        path.join(clientConfig.output.path, 'vue-ssr-client-manifest.json'),
        'utf-8'
      )
    );
  } catch (e) {
    console.log(e);
  }
  update();
});

// watch and update server renderer
const serverConfig = require('./build/webpack.server.config.js');
const serverCompiler = webpack({
  ...serverConfig,
  // TODO:
  mode: 'development',
});
const mfs = new MemoryFS();
serverCompiler.outputFileSystem = mfs;
serverCompiler.watch({}, (err, stats) => {
  try {
    serverBundle = JSON.parse(
      mfs.readFileSync(
        path.join(clientConfig.output.path, 'vue-ssr-server-bundle.json'),
        'utf-8'
      )
    );
  } catch (e) {
    console.log(e);
  }
  update();
});

// ...

10月メモ・リンク集

10月に調べたことのメモです。

Vue, Nuxt 関連

Migrate to Nuxt 2.0

参考:

Nuxt の options.ignore を利用して pages 内にコンテナやストアを配置

// nuxt.config.js
ignore: [
  // ...
  '**/pages/**/{containers,components}/*'
],

メリット:

  • 関係のあるコードを近くに配置する
  • pages, containers, store の関係をはっきりさせる
    • a page has a store and container(s)

変更前のディレクトリ構成:

.
├── containers
│    └── foo
│        ├── index.vue (<= コンテナコンポーネント)
│        ├── components
│        ├── store
│        └── utils
└── pages
    └── foo
        └── index.vue (<= ページコンポーネント)

変更後のディレクトリ構成:

.
└── pages
    └── foo
        ├── index.vue (<= ページコンポーネント)
        ├── components
        ├── containers
        │    └── index.vue (<= コンテナコンポーネント)
        ├── store
        └── utils

https://nuxtjs.org/api/configuration-ignore#the-ignore-property

ページ遷移時に権限確認

// middleware
// ...
// Get authorizations for matched routes (with children routes too)
const authorizationLevels = route.meta.map((meta) => {
  if (meta.auth && typeof meta.auth.authority !== 'undefined')
    return meta.auth.authority
  return 0
})
// Get highest authorization level
const highestAuthority = Math.max.apply(null, authorizationLevels)
// ...

不要なビルド処理を削減

  • API: build プロパティ - Nuxt.js
    • extend メソッドは一度はサーバーのバンドルのため、一度はクライアントのバンドルのため、つまり二度呼び出されます。

webpack.config.js (実装例):

module.exports = (config, options) => {
  // Add plugins
  config.plugins.push(
    // ...
  );
  // Do not run type checking twice. (This config is called twice,
  // one time for the server bundle, and one time for the client bundle.)
  if (options.isServer) {
    config.plugins.push(new ForkTSCheckerPlugin({
      vue: true
    }));
  }
  // ...
  return config;
};

文字コード正規表現

Unicode Property Escapes

補足:

文字コードの変換

['𩸽'.charCodeAt(0), '𩸽'.charCodeAt(1)].map(num => num.toString(16));
// => ["d867", "de3d"]

パスワード向け正規表現

例: 数字・アルファベット・記号を許容する

サンプル:

// 数字・小文字・大文字・記号を最低1文字含む, 8文字以上64文字以下
/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!-\/:-@[-`{-~])[!-~]{8,64}$/.test(value);

その他参考:

SVG 関連

SVG をアイコンフォントの代替にする

参考記事:

実装例 (Font Awesome):

<svg>
  <use xlink:href="fa-brands.svg#facebook"></use>
</svg>
<?xml version="1.0" encoding="UTF-8"?>
<!--
Font Awesome Free 5.5.0 by @fontawesome - https://fontawesome.com
License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
-->
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
  // ...
  <symbol id="facebook" viewBox="0 0 448 512">
    <title id="facebook-title">Facebook</title>
    <path d="..."></path>
  </symbol>
  // ...
</svg>

https://fontawesome.com/how-to-use/on-the-web/advanced/svg-sprites

書き出し (WIP):

  • svgo で不要タグなどを整理. 色情報消去(fill を空にする)
    • svgo *.svg --disable=removeTitle --config='{ "plugins": [ { "removeAttrs": { "attrs": "fill" } } ], "floatPrecision": xx }'
  • <symbol id="xxx" viewBox="0 0 xx xx"> を追加 (単体ファイルにする? or SVG Sprite にする?)
    • svgo で対応する or 別ツールを利用する

アウトプット例:

<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg"><title>title</title><symbol id="root" viewBox="0 0 24 24"><path d="..." fill-rule="evenodd"/></symbol></svg>

関連情報

SVG 基礎知識:

ツール・書き出し:

その他:

JIRA 芸

JIRA 使い始めました。

Form Events

書いた記事

その他