1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Vue2 のリアクティブシステムをプレーンな Javascript で実装する

Last updated at Posted at 2020-09-20

目的

Object.definePropertyの getter/setter を使って簡易的な Vue のリアクティブシステムを実装していくことがこの記事の目的です。実装は、Vue Mastery の「Build a Reactivity System」を参考にしています。

公式ドキュメントによると...

Vue2 のリアクティブシステムは、Object.definePropertyの getter/setter を使って実装されています。これについては Vue 公式ドキュメントの「リアクティブの探求」のセクションで触れられています。

プレーンな JavaScript オブジェクトを data オプションとして Vue インスタンスに渡すとき、Vue はその全てのプロパティを渡り歩いて、それらを Object.defineProperty を使用して getter/setter に変換します。
https://jp.vuejs.org/v2/guide/reactivity.html

Object.defineProperty のド基礎

Object.definePropertyの公式ドキュメントです。

ディスクリプターとは

Javascript のオブジェクトは、値(value)以外に詳細設定を持っています。詳細設定は、ディスクリプターと呼ばれており、例えば、書き換え可能なオブジェクトなのかどうか、などがあります。

// ふつうのオブジェクト。値以外に詳細設定を持つ。
const obj = { name: 0 };

ディスクリプターは、getOwnPropertyDescriptorで確認することができ、ふつうにオブジェクトを定義した場合のデフォルト値はtrueになります。例えば、書き換え可能なオブジェクトかどうかを表すディスクリプターはwritableであり、以下の例は、trueなので書き換え可能である、ということになります。

const obj = { name: 0 };
const descriptor = Object.getOwnPropertyDescriptor(obj, 'name')
console.log(descriptor)
// {value: 0, writable: true, enumerable: true, configurable: true}

definePropertyで定義した場合は、ふつうにオブジェクトを定義した場合の挙動と異なり、デフォルト値はfalseになります。

const obj = {};
Object.defineProperty(obj, 'name', {
    value: 43
})
console.log(obj)
Object.getOwnPropertyDescriptor(obj, 'name')
// {value: 43, writable: false, enumerable: false, configurable: false}

getter/setter もディスクリプター

getter/setter は、オプショナルなディスクリプターで、デフォルト値はundefinedなので、使いたい場合は追加で定義する必要があります。

const obj = { _name: 'qiita' };
Object.defineProperty(obj, 'name', {
    get: function() {
        return this._name;
    },
    set: function (val) {
        this._name = val
    }
})
obj.name
// "qiita"

obj.name = 'twitter'
// "twitter"

obj.name
// "twitter"

obj.nameでは、getter経由でqiitaという戻り値があり、obj.name = 'twitter'では、setter経由でtwitterを書き換えています。

ここらへんから、けっこう Vue のリアクティブなコードの記法と一緒なので、これが Vue のリアクティブシステムのコアであると言われてるとわりとすんなり理解できる気がしています。

変更が追跡できないパターン

Object.definePropertyの getter/setter によってリアクティブシステムは実装されているため、 getter/setter のリアクティブの限界が Vue のリアクティブの限界でもあります(一部は Vue が補っている部分もあるため、完全なイコールではないです)。

例えば、配列への要素の追加の変更は追跡できないため、リアクティブになりません。

var vm = new Vue({
  data: {
    items: ['a', 'b', 'c']
  }
})
vm.items[1] = 'x' // is NOT reactive
vm.items.length = 2 // is NOT reactive

リアクティブシステムを Javascript で実装する

目的地

すごくシンプルなpricequantityの乗算によって、totalPriceを算出する Vue のコードをObject.definePropertyの getter/setter を使って実装していきます。

以下は、すごくシンプルな Vue のコードです。これと同じことを Javasciript で実装します。

<div id="app">
    <div>Price: ${{ price }}</div>
    <div>Total: ${{ totalPrice }}</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script>
  var vm = new Vue({
    el: '#app',
    data: {
      price: 5.00,
      quantity: 2
    },
    computed: {
      totalPrice() {
        return this.price * this.quantity
      }
    }
  })
</script>

実装する ~その1~

まずは、オブジェクトの定義から書いていきます。

let data = { price: 5, quantity: 2 }

次は、priceあるいはquantityの値が変更されたときの再計算の関数を定義します.
targetが呼ばれ、data.totalの再計算が行われます。

let target = null
target = function () {
    data.total = data.price * data.quantity
}

次にdataに対して、getter/setter を設定します。

let internalValue = data.price
Object.defineProperty(data, 'price', {
    get() {
        return internalValue
    },
    set(newVal) {
        internalValue = newVal
        target()
    }
})

現時点の動作を確認すると、data.price = 10の実行により、setter が呼ばれ、その中でtarget()が実行されることで、data.totalが更新されています。

data.total
// undefined
data.price = 10
// 10
data.total
// 20

ここまで実装すれば、リアクティブの実装の仕組みは理解できたはずです。

実装する ~その2~

このコーディングには以下の2点の問題があります。

  • quantity のリアクティブの実装はまだしていない
  • コードに柔軟性が足りない

quantityの getter/setter を定義したいですが、もう一つdefinePropertyを書くのはナンセンスです。また、今はpriceの値が変更されればtarget()を実行することを決め打ちで書いているので、set()に直接、target()を書いていますが、他の関数にしたいなどの要望があると追加しづらいです。

そのため、次は、依存関係を管理するクラスを定義します。
ようは、data.priceの値が書き換えられたら、target()を実行する必要がある、という依存関係を管理します。

Depクラスを定義し、値が変更されたときに実行する関数(依存関係)をdepend()によってsubscribersリストに格納しています。これはあくまでも依存関係を取得しているのみであり、実際に値が変更されたときは、notify()によって、実行する関数を呼びます。

class Dep {
  constructor () {
    this.subscribers = [] 
  }
  depend() {
    if (target && !this.subscribers.includes(target)) {
      this.subscribers.push(target)
    } 
  }
  notify() {
    this.subscribers.forEach(sub => sub())
  }
}

dataのキーが複数でも問題ないようにObject.keysでの実装に書き換えています。また、get()dep.depend()を実行することで、依存関係を取得し、値が書き換えられるset()dep.notify()を実行することで、実行する関数を読んでいます。

Object.keys(data).forEach(key => {
    let internalValue = data[key]
    const dep = new Dep()

    Object.defineProperty(data, key, {
        get() {
            dep.depend()
            return internalValue
        },
        set(newVal) {
            internalValue = newVal
            dep.notify()

        }
    })
})

最後にwatcher()を定義し、ここでやっとtarget()は実行されます。watcher()でラップしているのは、target()以外の関数でもいいようにです。

function watcher(myFunc) {
    target = myFunc
    target()
    target = null
}

watcher(target)

最終成果物

実行結果。

data.price = 50
// 50
data.total
// 100
data.quantity = 100
// 100
data.total
// 5000

コード。

let data = { price: 5, quantity: 2 }

let target = null
target = function () {
    data.total = data.price * data.quantity
}

class Dep {
    constructor () {
    this.subscribers = [] 
    }
    depend() {
    if (target && !this.subscribers.includes(target)) {
        // Only if there is a target & it's not already subscribed
        this.subscribers.push(target)
    } 
    }
    notify() {
    this.subscribers.forEach(sub => sub())
    }
}

Object.keys(data).forEach(key => {
    let internalValue = data[key]
    const dep = new Dep()

    Object.defineProperty(data, key, {
        get() {
            dep.depend()
            // console.log('i was assessed', internalValue)
            return internalValue
        },
        set(newVal) {
            // console.log('i was changed', internalValue, newVal)
            internalValue = newVal
            dep.notify()

        }
    })
})

function watcher(myFunc) {
    target = myFunc
    target()
    target = null
}
watcher(target)

まとめ

definePropertyの基礎から、簡単な Vue のリアクティブの実装をしました。
かなりシンプルですが、Vue のリアクティブシステムの実装ができたかと思います。

Vue3 からは、リアクティブの実装はProxyオブジェクトを使用されているため、少し挙動は変わるようなので、次はそっちも見ていきたいです。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?