⚒️

vue-loader 15で、vue テンプレート内の任意の属性(data-testなど)を除外する

13 min read

概要

<template>
  <div class="hoge" data-test="Hoge">
</template>

上記のdata-testのような、テストでは使いたいけどプロダクションコードには反映させたくないような属性を、vue-loaderの設定でプロダクションビルド時には除外し、以下のようにします。

<template>
  <div class="hoge">
</template>

内容はVueコンポーネントのビルド時に不要な属性をtemplateから取り除く にかかれていることを、vue-loader 15系に合わせて書き直しつつ深堀りしただけになります。

バージョン情報

version
node 12.16.3
vue 2.6.11
vue-loader 15.9.1
webpack 4.43.0

対象コンポーネント

以下のような、要素それぞれに入れ子があってdata-test属性以外にもちょいちょい属性が付与されているようなサンプルコードをビルド対象として、data-test属性のみ、テスト専用のメタデータなので除外したいと思います。

<template>
  <div class="root">
    <p data-test="Hoge">hoge</p>
    <div data-test="Fuga">
      <h1 id="foo" data-test="Foo">foo</h1>
      <h2 id="bar" data-test="Bar">bar</h2>
      <p :data-test="value">Hello</p>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      value: "Hello"
    }
  }
}
</script>

vue-loaderの構成について

本記事では便宜上、webpackvue-loader を使うための設定を、以下のようなモジュールに切り出していることを前提とします。

vue.js
module.exports = {
  test: /\.vue$/,
  use: [
    {
      loader: 'vue-loader',
      options: {}
    }
  ]
}

テンプレートのコンパイルに干渉する

vue-loader は内部的に、 vue-template-compiler を使って、Vueファイルの <template> を、 createElement を使ったピュアな Javascript に変換します。

変換の際に一度ASTを経由しますが、vue-template-compiler では、その変換過程に任意の関数をフックすることができます。

vue-loader の設定からvue-template-compilerの設定を指定する場合、 compilerOptions を指定します。

干渉できるタイミングはいくつかありますが、今回は <template> がASTに変換された直後のタイミングを使いたいので、 preTransformNode を使用します。

vue.js
module.exports = {
  test: /\.vue$/,
  use: [
    {
      loader: 'vue-loader',
      options: {
        compilerOptions: {
          modules: [
            {
              preTransformNode(astEl) {
                console.log(astEl)
              }
            }
          ]
        }
      }
    }
  ]
}

この関数は、 <template> をコンパイルする際の要素1個につき、その要素を表すASTを引数にして呼ばれます。

<template>
  <div class="root">
    <p data-test="Hoge">hoge</p>
    <div data-test="Fuga">
      <h1 id="foo" data-test="Foo">foo</h1>
      <h2 id="bar" data-test="Bar">bar</h2>
      <p :data-test="value">Hello</p>
    </div>
  </div>
</template>

の場合、親子合わせて全部で以下の6要素あるので、6回関数が呼ばれるわけですね。

  • div.root
  • p[data-test=Hoge]
  • div[data-test=Fuga]
  • h1#foo
  • h2#bar
  • p[data-test=value]

これでWebpackビルドをすると以下のように、対象コンポーネントのASTオブジェクト(6種)がドバっと出てきます。

{
  type: 1,
  tag: 'div',
  attrsList: [ { name: 'class', value: 'root', start: 5, end: 17 } ],
  attrsMap: { class: 'root' },
  rawAttrsMap: { class: { name: 'class', value: 'root', start: 5, end: 17 } },
  parent: undefined,
  children: [],
  start: 0,
  end: 18
}
{
  type: 1,
  tag: 'p',
  attrsList: [ { name: 'data-test', value: 'Hoge', start: 24, end: 40 } ],
  attrsMap: { 'data-test': 'Hoge' },
  rawAttrsMap: {
    'data-test': { name: 'data-test', value: 'Hoge', start: 24, end: 40 }
  },
  parent: {
    type: 1,
    tag: 'div',
    attrsList: [ [Object] ],
    attrsMap: { class: 'root' },
    rawAttrsMap: { class: [Object] },
    parent: undefined,
    children: [],
    start: 0,
    end: 18
  },
  children: [],
  start: 21,
  end: 41
}
{
  type: 1,
  tag: 'div',
  attrsList: [ { name: 'data-test', value: 'Fuga', start: 57, end: 73 } ],
  attrsMap: { 'data-test': 'Fuga' },
  rawAttrsMap: {
    'data-test': { name: 'data-test', value: 'Fuga', start: 57, end: 73 }
  },
  parent: {
    type: 1,
    tag: 'div',
    attrsList: [ [Object] ],
    attrsMap: { class: 'root' },
    rawAttrsMap: { class: [Object] },
    parent: undefined,
    children: [ [Object], [Object] ],
    start: 0,
    end: 18
  },
  children: [],
  start: 52,
  end: 74
}
{
  type: 1,
  tag: 'h1',
  attrsList: [
    { name: 'id', value: 'foo', start: 83, end: 91 },
    { name: 'data-test', value: 'Foo', start: 92, end: 107 }
  ],
  attrsMap: { id: 'foo', 'data-test': 'Foo' },
  rawAttrsMap: {
    id: { name: 'id', value: 'foo', start: 83, end: 91 },
    'data-test': { name: 'data-test', value: 'Foo', start: 92, end: 107 }
  },
  parent: {
    type: 1,
    tag: 'div',
    attrsList: [ [Object] ],
    attrsMap: { 'data-test': 'Fuga' },
    rawAttrsMap: { 'data-test': [Object] },
    parent: {
      type: 1,
      tag: 'div',
      attrsList: [Array],
      attrsMap: [Object],
      rawAttrsMap: [Object],
      parent: undefined,
      children: [Array],
      start: 0,
      end: 18
    },
    children: [],
    start: 52,
    end: 74
  },
  children: [],
  start: 79,
  end: 108
}
{
  type: 1,
  tag: 'h2',
  attrsList: [
    { name: 'id', value: 'bar', start: 125, end: 133 },
    { name: 'data-test', value: 'Bar', start: 134, end: 149 }
  ],
  attrsMap: { id: 'bar', 'data-test': 'Bar' },
  rawAttrsMap: {
    id: { name: 'id', value: 'bar', start: 125, end: 133 },
    'data-test': { name: 'data-test', value: 'Bar', start: 134, end: 149 }
  },
  parent: {
    type: 1,
    tag: 'div',
    attrsList: [ [Object] ],
    attrsMap: { 'data-test': 'Fuga' },
    rawAttrsMap: { 'data-test': [Object] },
    parent: {
      type: 1,
      tag: 'div',
      attrsList: [Array],
      attrsMap: [Object],
      rawAttrsMap: [Object],
      parent: undefined,
      children: [Array],
      start: 0,
      end: 18
    },
    children: [ [Object], [Object] ],
    start: 52,
    end: 74
  },
  children: [],
  start: 121,
  end: 150
}
{
  type: 1,
  tag: 'p',
  attrsList: [ { name: ':data-test', value: 'value', start: 166, end: 184 } ],
  attrsMap: { ':data-test': 'value' },
  rawAttrsMap: {
    ':data-test': { name: ':data-test', value: 'value', start: 166, end: 184 }
  },
  parent: {
    type: 1,
    tag: 'div',
    attrsList: [ [Object] ],
    attrsMap: { 'data-test': 'Fuga' },
    rawAttrsMap: { 'data-test': [Object] },
    parent: {
      type: 1,
      tag: 'div',
      attrsList: [Array],
      attrsMap: [Object],
      rawAttrsMap: [Object],
      parent: undefined,
      children: [Array],
      start: 0,
      end: 18
    },
    children: [ [Object], [Object], [Object], [Object] ],
    start: 52,
    end: 74
  },
  children: [],
  start: 163,
  end: 185
}

今回は各要素の属性(attr)情報だけを知りたいので、 astEl のうち、必要なプロパティだけ抜き出してもう一度見てみましょう。

preTransformNode(astEl) {
  const { attrsList, attrsMap } = astEl
  console.log({ attrsList, attrsMap })
  console.log('------')
}
{
  attrsList: [ { name: 'class', value: 'root', start: 5, end: 17 } ],
  attrsMap: { class: 'root' }
}
------
{
  attrsList: [ { name: 'data-test', value: 'Hoge', start: 24, end: 40 } ],
  attrsMap: { 'data-test': 'Hoge' }
}
------
{
  attrsList: [ { name: 'data-test', value: 'Fuga', start: 57, end: 73 } ],
  attrsMap: { 'data-test': 'Fuga' }
}
------
{
  attrsList: [
    { name: 'id', value: 'foo', start: 83, end: 91 },
    { name: 'data-test', value: 'Foo', start: 92, end: 107 }
  ],
  attrsMap: { id: 'foo', 'data-test': 'Foo' }
}
------
{
  attrsList: [
    { name: 'id', value: 'bar', start: 125, end: 133 },
    { name: 'data-test', value: 'Bar', start: 134, end: 149 }
  ],
  attrsMap: { id: 'bar', 'data-test': 'Bar' }
}
------
{
  attrsList: [ { name: ':data-test', value: 'value', start: 166, end: 184 } ],
  attrsMap: { ':data-test': 'value' }
}
------

わかりやすくなってきました。
attrsList は対象要素に設定されている属性の一覧で、 attrsMap は属性名に対する設定値のマッピングですね。

ビルド結果を確認する①

webpack でのビルドが完了すると、 output で指定したパスにアセットファイルが生成されます。

それを覗いてみると、先程出力したASTをベースに、ピュアなJavascriptでVueオブジェクトを生成しているコードが見つかります。

var render = function () {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c("div", { staticClass: "root" }, [
    _c("p", { attrs: { "data-test": "Hoge" } }, [_vm._v("hoge")]),
    _vm._v(" "),
    _c("div", { attrs: { "data-test": "Fuga" } }, [
      _c("h1", { attrs: { id: "foo", "data-test": "Foo" } }, [_vm._v("foo")]),
      _vm._v(" "),
      _c("h2", { attrs: { id: "bar", "data-test": "Bar" } }, [_vm._v("bar")]),
      _vm._v(" "),
      _c("p", { attrs: { "data-test": _vm.value } }, [_vm._v("Hello")]),
    ]),
  ])
}

この状態だと、プロダクションコードにも data-test 属性が入ってしまうので、それをASTの時点で削除しましょう。

data-test 属性を削除する

preTransformNode のような関数は、ASTオブジェクトを受け取ってASTオブジェクトを戻します。その際、戻したほうのASTオブジェクトを使用してコンパイルが継続されるので、この関数内で、 data-test 属性を削除したASTオブジェクトを戻してあげればよいわけです。

と言っても非常に簡単で、先程の attrsList attrsMap から該当のデータだけ削除してあげればOKです。

preTransformNode(astEl) {
  const { attrsList, attrsMap } = astEl
  if (attrsMap['data-test']) {
    delete attrsMap['data-test']
    const index = attrsList.findIndex(x => x.name === 'data-test')
    attrsList.splice(index, 1)
  }
  return astEl
}

これによって、もし対象要素が data-test 属性を持っていた場合、attrsMap 及び attrsList から削除し、編集後のASTオブジェクトを戻すようになります。

これでビルドしてみましょう。

{
  attrsList: [ { name: 'class', value: 'root', start: 5, end: 17 } ],
  attrsMap: { class: 'root' }
}
-----
{ attrsList: [], attrsMap: {} }
-----
{ attrsList: [], attrsMap: {} }
-----
{
  attrsList: [ { name: 'id', value: 'foo', start: 83, end: 91 } ],
  attrsMap: { id: 'foo' }
}
-----
{
  attrsList: [ { name: 'id', value: 'bar', start: 125, end: 133 } ],
  attrsMap: { id: 'bar' }
}
-----
{
  attrsList: [ { name: ':data-test', value: 'value', start: 166, end: 184 } ],
  attrsMap: { ':data-test': 'value' }
}
-----

惜しい! data-test 属性は見事に消えていますが、動的バインディングの :data-test 属性が残ってしまいました。

これらはVueコンポーネント描画後のDOM上では同じ属性になりますが、この段階のASTでは全く異なる属性として扱われます。

やや冗長になっちゃうので、汎用的なコードを抜き出しましょう。

function removeAttr(astEl, name) {
  const { attrsList, attrsMap } = astEl
  if (attrsMap[name]) {
    delete attrsMap[name]
    const index = attrsList.findIndex(x => x.name === name)
    attrsList.splice(index, 1)
  }
  return astEl
}

preTransformNode からは、二つの属性を指定して関数を呼び出すだけにしてあげます。

preTransformNode(astEl) {
  const { attrsList, attrsMap } = astEl
  removeAttr(astEl, 'data-test')
  removeAttr(astEl, ':data-test')
  console.log({ attrsList, attrsMap })
  console.log('-----')
  return astEl
}
{
  attrsList: [ { name: 'class', value: 'root', start: 5, end: 17 } ],
  attrsMap: { class: 'root' }
}
-----
{ attrsList: [], attrsMap: {} }
-----
{ attrsList: [], attrsMap: {} }
-----
{
  attrsList: [ { name: 'id', value: 'foo', start: 83, end: 91 } ],
  attrsMap: { id: 'foo' }
}
-----
{
  attrsList: [ { name: 'id', value: 'bar', start: 125, end: 133 } ],
  attrsMap: { id: 'bar' }
}
-----
{ attrsList: [], attrsMap: {} }
-----

data-test :data-test ともに削除できました。完璧そうですね。
しかもこの構成なら test 以外に消しておきたい属性が出てきても汎用的に対応できます。

ビルド結果を確認する①

ではこの状態でWebpackのビルド結果をもう一度見てみましょう。

var staticRenderFns = [
  function () {
    var _vm = this
    var _h = _vm.$createElement
    var _c = _vm._self._c || _h
    return _c("div", { staticClass: "root" }, [
      _c("p", [_vm._v("hoge")]),
      _vm._v(" "),
      _c("div", [
        _c("h1", { attrs: { id: "foo" } }, [_vm._v("foo")]),
        _vm._v(" "),
        _c("h2", { attrs: { id: "bar" } }, [_vm._v("bar")]),
        _vm._v(" "),
        _c("p", [_vm._v("Hello")]),
      ]),
    ])
  },
]

しっかり data-test :data-test 属性だけが除外され、 id class といった属性は残っていることが確認できました!

プロダクション環境でのみ除外する

ここまでで data-test :data-test を除外できましたが、普通にテストを実行する際は残っていてほしいので、本番用にビルドするときのみ除外するようにしたいですね。

process.env.NODE_ENV に環境情報が入ってるとすると、以下のように整理すればプロダクションのみで除外できそうです。

/**
 * ASTオブジェクトから任意の属性を除外して戻す
 * @param {Object} astEl vue-loaderが単一要素をAST化したオブジェクト
 * @param {string} name  ASTから除外する属性名
 */
function removeAttr(astEl, name) {
  const { attrsList, attrsMap } = astEl
  if (attrsMap[name]) {
    delete attrsMap[name]
    const index = attrsList.findIndex(x => x.name === name)
    attrsList.splice(index, 1)
  }
  return astEl
}

/**
 * ASTオブジェクトからE2Eテスト用のカスタム属性を除外する
 * @param {Object} astEl vue-loaderが単一要素をAST化したオブジェクト
 */
function removeTestAttr(astEl) {
  removeAttr(astEl, 'data-test')
  removeAttr(astEl, ':data-test')
  return astEl
}

// プロダクション環境の場合のみ、data-test属性は抹消する
const compileModules = process.env.NODE_ENV === 'production' ? [{ preTransformNode: removeTestAttr }] : []

module.exports = {
  test: /\.vue$/,
  use: [
    {
      loader: 'vue-loader',
      options: {
        extractCSS: true,
        compilerOptions: {
          modules: compileModules
        }
      }
    }
  ]
}