🔢

input type=”number"とv-model考 〜Vue本体のコードを添えて〜

2022/12/15に公開

このエントリーは Vue Advent Calendar 2022 15日目の記事です。

自分はあるVue.jsを使ったOSSに新規UIを追加しようとしていて、<input type="number" v-model="someNumber">を使用しようとしていたのですが、いくつか疑問や深堀りしたい点があったので記事にまとめてみました。
Vue.jsをされてる方以外にも面白いと思ってもらえるよう書いてみたところもあるので、ぜひ記事のフィードバックをいただけると嬉しいです。

まず、<input type="number">の仕様からおさらい

みなさまは<input type="number">に何が入力できるかご存知でしょうか?

わかりやすい公式に近い説明が見当たらなかったのでLiving Standardを策定するWHATWGのサイトを読んでみましょう。

https://html.spec.whatwg.org/multipage/input.html#number-state-(type=number)

The value attribute, if specified and not empty, must have a value that is a valid floating-point number.
valueは指定されかつ空でない場合、有効な浮動小数点数であることが必要である

The value sanitization algorithm is as follows: If the value of the element is not a valid floating-point number, then set it to the empty string instead.
値のサニタイズアルゴリズムは次のとおりである。要素の値が有効な浮動小数点数でない場合、代わりに空文字列を設定する。

その、valid floating-point numberリンクになっていたのでその先を読んでみましょう。

A string is a valid floating-point number if it consists of:

  1. Optionally, a U+002D HYPHEN-MINUS character (-).
  2. One or both of the following, in the given order:
    1. A series of one or more ASCII digits.
    2. Both of the following, in the given order:
      1. A single U+002E FULL STOP character (.).
      2. A series of one or more ASCII digits.
  3. Optionally:
    1. Either a U+0065 LATIN SMALL LETTER E character (e) or a U+0045 LATIN CAPITAL LETTER E character (E).
    2. Optionally, a U+002D HYPHEN-MINUS character (-) or U+002B PLUS SIGN character (+).
    3. A series of one or more ASCII digits.

ASCII digitsはアスキー数字のことのようです。一旦は数字のこと、と思ってよさそうです。
かんたんに機械翻訳も用いつつ訳してみると、

  1. オプションとして、-記号
  2. 次のうちの1つまたは両方(指定された順序で)
    1. 1つまたは複数のアスキー数字の連続
    2. 次のうちの1つまたは両方(指定された順序で)
      1. .一つ
      2. 1つまたは複数のアスキー数字の連続
  3. オプションとして
    1. eまたはE
    2. オプションとして、-記号または+記号
    3. 1つまたは複数のアスキー数字の連続

こちらの内容で正確にどのようなバリデーションが行われているのか理解するのは難しいのですが、かいつまんでみると、valueに含まれるのは-+eまたはE、アスキー数字、または空文字となることが想定されそうです。

この記事でもそのように言及されていました。eまたはEは指数表記の表現のため…の説が強いようです。

https://qiita.com/y-temp4/items/881b7c0dad7b369e8bf8

指数表記は、JavaScriptでもnumber型に含まれています。
しかし、eの場所に誤りがあれば下記のようにinvalidなものとなってしまいます。

typeof 2e4 // number
typeof 2e4e // Uncaught SyntaxError: Invalid or unexpected token

+付きも先頭であればnumber型に含まれます。

typeof +400 // number 

このあたりを実際にどう処理しているのかを、まずはVueを介さない通常のinputの値をコンソールで取得して見てみましょう。

// <input type="number" value="4" id="numberInput">

// 4そのまま出力
document.getElementById('numberInput').value
'4'

// inputに+4と入力
document.getElementById('numberInput').value
'4'

// inputにeと入力
document.getElementById('numberInput').value
''

// inputに2e4と入力
document.getElementById('numberInput').value
'2e4'

// inputに2e40と入力
document.getElementById('numberInput').value
'2e40'

返ってくるのは文字列です。+は無視されています。eとだけ入力した場合空文字が返ってくるのは、前述した要素の値が有効な浮動小数点数でない場合、代わりに空文字列を設定する。というところの処理がなされているのかと思います。
確認に2e40を含めたのはあとの流れに関係があります。

Vue2はmodifierにnumberを指定すればnumber型が返ってきて、Vue3の場合はinput type="number"であれば指定なしでもnumber型が返ってくるはずです。
しかしそこに空文字 = string型も含まれてくるのでしょうか。気になってきました。
Vue3の<input type="number" v-model="someNumber">に今度は置き換えて、console.logしてみます。

// 4を入力。また、+4は変化なし。
RefImpl {__v_isShallow: false, dep: Set(2), __v_isRef: true, _rawValue: 4, _value: 4}
// 2eなどinvalidな表記
RefImpl {__v_isShallow: false, dep: Set(2), __v_isRef: true, _rawValue: '', _value: ''}
// 2e4
RefImpl {__v_isShallow: false, dep: Set(2), __v_isRef: true, _rawValue: 20000, _value: 20000}
// 2e40
RefImpl {__v_isShallow: false, dep: Set(2), __v_isRef: true, _rawValue: 2e+40, _value: 2e+40}

有効な浮動小数点数の場合、返ってきているのはnumber型の数値です。
空文字は空文字として処理されているので、この時点でinput type="number"のv-modelの処理内のvalueの型がstring | numberが含まれることが想定されます。
そして2e4は指数表記が10進法の数値に処理された値が返ってきていますが、2e402e+40というものに置き換わっています。

このあたりの処理に興味を持ったので、自分はVue3本体のコードを見てみることにしました。

https://github.com/vuejs/core/blob/main/packages/runtime-dom/src/directives/vModel.ts

v-modelが付与されたinputのtypeごとに処理の関数が別れているようで、numberはvModelText関数を用いており、input type="text"の場合と一緒に処理されているようでした。

// We are exporting the v-model runtime directly as vnode hooks so that it can
// be tree-shaken in case v-model is never used.
export const vModelText: ModelDirective<
  HTMLInputElement | HTMLTextAreaElement
> = {
  created(el, { modifiers: { lazy, trim, number } }, vnode) {
    el._assign = getModelAssigner(vnode)
    const castToNumber =
      number || (vnode.props && vnode.props.type === 'number')
    addEventListener(el, lazy ? 'change' : 'input', e => {
      if ((e.target as any).composing) return
      let domValue: string | number = el.value
      if (trim) {
        domValue = domValue.trim()
      }
      if (castToNumber) {
        domValue = looseToNumber(domValue)
      }
      el._assign(domValue)
    })
    if (trim) {
      addEventListener(el, 'change', () => {
        el.value = el.value.trim()
      })
    }
    if (!lazy) {
      addEventListener(el, 'compositionstart', onCompositionStart)
      addEventListener(el, 'compositionend', onCompositionEnd)
      // Safari < 10.2 & UIWebView doesn't fire compositionend when
      // switching focus before confirming composition choice
      // this also fixes the issue where some browsers e.g. iOS Chrome
      // fires "change" instead of "input" on autocomplete.
      addEventListener(el, 'change', onCompositionEnd)
    }
  },
  // set value on mounted so it's after min/max for type="range"
  mounted(el, { value }) {
    el.value = value == null ? '' : value
  },
  beforeUpdate(el, { value, modifiers: { lazy, trim, number } }, vnode) {
    el._assign = getModelAssigner(vnode)
    // avoid clearing unresolved text. #2302
    if ((el as any).composing) return
    if (document.activeElement === el && el.type !== 'range') {
      if (lazy) {
        return
      }
      if (trim && el.value.trim() === value) {
        return
      }
      if (
        (number || el.type === 'number') &&
        looseToNumber(el.value) === value
      ) {
        return
      }
    }
    const newValue = value == null ? '' : value
    if (el.value !== newValue) {
      el.value = newValue
    }
  }
}

一つ一つ追っていきます。

const castToNumber =
    number || (vnode.props && vnode.props.type === 'number')

のところで、modifierがnumber(v-model.number)もしくはinputのtypeがnumberだった時、フラグが立っているようです。

下記にある通り、やはり値はstring | numberとして扱われているようです。

let domValue: string | number = el.value

そして下記の部分で、castToNumberがTruthyの場合looseToNumberという関数の処理が行われています。

if (castToNumber) {
  domValue = looseToNumber(domValue)
}

そのlooseToNumberの処理を見に行きましょう。

https://github.com/vuejs/core/blob/main/packages/shared/src/index.ts

/**
 * "123-foo" will be parsed to 123
 * This is used for the .number modifier in v-model
 */
export const looseToNumber = (val: any): any => {
  const n = parseFloat(val)
  return isNaN(n) ? val : n
}

どうやら、parseFloatを用いているようです。
そしてその結果がisNaNでtrueであれば値そのものを返し、falseであればparseFloatされたものを返しているようです。

parseFloatは、入力された値が数値として処理できない場合はNaNを返す仕様になっています。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/parseFloat

こちらで、引数がparseFloatで変換出来なかった文字列であった場合はその値がそのまま返される処理となっているようです。

コードのコメントは下記のように書かれています。

"123-foo" will be parsed to 123
This is used for the .number modifier in v-model
"123-foo"は123とパースされる
これは.numberのmodifierをv-modelで用いた時に使われます

この関数は、Vue2のtoNumberと型以外同様です。

https://github.com/vuejs/vue/blob/main/src/shared/util.ts

Vue3のコメントのほうが、parseFloatの仕様がわかりやすいですが、<input type="number">で用いる場合にとってはVue2のコメントのほうがわかりやすいかもしれないですね。

Convert an input value to a number for persistence.
If the conversion fails, return original string.
入力値を数値に変換して保存する。
変換に失敗した場合は、元の文字列を返す。

この空文字が返ってくることを考慮すると、実は下記のようなコードは落とし穴があります。

// someNumber = v-modelの値
watch(someNumber, () => {
    if (someNumber.value < 1) {
        someNumber.value = 1
    }
})

このコードは、1以下が手動で入力された場合に1に補正するコードです。(手入力は制御できないため、このようなコードを一時期書いていました)
しかし、someNumber.valueが空文字だった場合、比較演算子により空文字が0に変換され、ifブロックの中に入ってしまいます。
この場合、手動での入力で空にしようとすると1が必ず入ってきてしまうので、任意の数字が入力できなくなります。
こういった場合を回避するには、someNumber.valueがnumber型であるかなど検査するのがいいのかもしれません。

(余談)JavaScriptの指数表記

話が脇道に逸れますが、2e40が10進法に直されず2e+40になっていた件が気になって夜しか眠れません。
このところを正式な仕様から掘り返すのは難しそうだと思ったのですが、いくつか正解に近い情報をたどってみました。

number型として処理された時に行われる10進法の変換そのもので使っているメソッドではないと思いますが、toFixedのMDNに気になる記述がありました。

numObj の絶対値が 1e+21 以上の場合は、このメソッドは単純に Number.prototype.toString() を呼び出し、指数表記での文字列を返します。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Number/toFixed#解説

実際のところ、1e+21をコンソールに入力すると1e+21が返ってきて、1e+20を入力すると100000000000000000000が返ってくるのでここに境界があるようです。
10進数で表してくれるのは21桁が限界…なのかもしれません。

また、プラス記号が紛れてくる処理については、これも実際に変換しているメソッドではないかもしれませんがECMAに書かれているtoExponentialの処理が参考になりそうだと思いました。
こちらの処理の最終結果の中のcの中にプラスかマイナス記号が入ってくるようになっているので、JavaScriptが指数表記を表そうとするとeのあとに+記号が入ってくるという風に思っておけばいいのかもしれません。

https://262.ecma-international.org/5.1/#sec-15.7.4.6

指数表記の下りは謎が多いので、識者の方のご意見もお待ちしています。

おわりに

Vue.jsのアドベントカレンダーなのに<input type="number">や指数表記の仕様など脇道にそれまくっててすみません。
ただ、こうやって疑問に思った点を実際にOSSのコードを見に行くのはすごくためになるので、楽しいです。
みなさまもいいVue.jsライフを!

Discussion