Open55

『Vue.js设计与实现』読書メモ

Shinya FujinoShinya Fujino

積読していた本書を二、三日前に読み始めた(物理)。タイトルを和訳すると「Vue.js の設計と実装」のようになるだろうか。

よくある技術書は「ある技術の使い方」を学ぶことに重点が置かれているが、これは「技術の作り方」、特に Vue.js を主軸としてフロントエンドライブラリの設計や実装について解説している。しかも、解説しているのは Vue.js のコアチームメンバーの HcySunYang であるため、中の人によるライブラリの設計解説書籍という、あまり他に例がないものになっている。

https://github.com/HcySunYang

あとで簡単に概略を追えるように、ここにメモを残していく。

Shinya FujinoShinya Fujino

(なお、自分の中国語力は英語と比較してかなり劣るため、読み通せる自信はない...)

Shinya FujinoShinya Fujino

第 1 章 权衡的艺术

Shinya FujinoShinya Fujino

タイトルは「利害得失を比較して判断する技術」というような意味。ライブラリやフレームワークを作成する際、前提として多くの点について様々な角度から比較・検討しなければならないということがまず述べられる。そして具体的に以下のテーマが例として論じられる:

  • Imperative vs Declarative
  • Performance vs Maintainability
  • 仮想 DOM の性能について
  • Runtime vs Compile Time
Shinya FujinoShinya Fujino

1.1 命令式和声明式
Imperative に UI を記述する場合、求める結果に至るまでの過程を記述し、一方、Declarative に記述する場合、求める結果そのものを描くという、基本が説明される。Vue は内部で結果に至る過程を肩代わりしている。

Shinya FujinoShinya Fujino

1.2 性能与可维护性的权衡
性能に着目した場合、Declarative なコードが Imperative なコードに勝ることはない。なぜなら、UI を更新するコストを A、差分を計算するコストを B としたとき、

  • Imperative なコードのコスト = A
  • Declarative なコードのコスト = B + A

となるため。一方、保守性という観点では Declarative なコードが勝る。

よって、Declarative な UI の記述を可能とするライブラリを作成する場合、そのライブラリを使用したコードの保守性が高まるとともに、性能面での劣化ができる限り低減されるよう意識する必要がある。

Shinya FujinoShinya Fujino

1.3 虚拟 DOM 的性能到底如何

  • ページの生成コスト
    • 仮想 DOM
      • オブジェクトの計算 (VNode)
      • すべての DOM 要素の生成
    • innerHTML
      • HTML 文字列の計算
      • すべての DOM 要素の生成

であり、仮想 DOM と innerHTML によるページ生成コストはそれほど大きな差はない (「オブジェクトの計算」と「HTML 文字列の計算」は、DOM の操作に対して十分に小さいとみなす)。一方、

  • ページの更新コスト
    • 仮想 DOM
      • 新しいオブジェクトの計算
      • Diff
      • 更新が必要な DOM のみ更新する
    • innerHTML
      • HTML 文字列の計算
      • 既存の DOM の廃棄
      • 新しい DOM の生成

となり、更新に関しては仮想 DOM に軍配が上がる。これらを総合すると、パフォーマンスに関して

  • innerHTML < 仮想 DOM

であり、これと document.createElement などによる DOM 操作が理論上最速であることを考慮すると、パフォーマンスに関して

  • innerHTML < 仮想 DOM < ネイティブの DOM 操作

という不等式が成立する。

ただし、コードの保守性や読解に関する心理的負担も併せて考慮すると、仮想 DOM は優れたオプションといえる。

Shinya FujinoShinya Fujino

1.4 运行时和编译时
ランタイムとコンパイルタイムのどちらに処理を寄せるかについても考察が必要であることが語られる。Vue は両者を備えており、コンパイルによる最適化とランタイムの処理を保持することによる柔軟性の維持を目指している。

Shinya FujinoShinya Fujino

第 2 章 框架设计的核心

Shinya FujinoShinya Fujino

「フレームワーク設計におけるコア要素」として、以下の論点について議論される:

  • 開発者体験の向上
  • パッケージサイズの調整
  • Tree-Shaking
  • ビルド出力
  • 特定機能の有効化/無効化
  • エラーハンドリング
  • TypeScript サポート
Shinya FujinoShinya Fujino

2.1 提升用户的开发体验
適切なエラーメッセージを提供することにより、ユーザーが問題を迅速に発見する一助となる。開発者体験は、フレームワークの優劣の指標の一つといえる。

Shinya FujinoShinya Fujino

2.2 控制框架代码的体积
開発者体験を高めるための機能が増えることで、フレームワークのコード量が増えてしまうが、これはクライアントがダウンロード・実行するコードの量を増やすことにつながる。これを避けるために、__DEV__ などの定数を用いて開発時の機能を分岐の中に入れ、__DEV__false の際はその機能が dead code となるようにすることで、開発用とプロダクション用で異なるコードを出力する、というアイデアが示される。

Shinya FujinoShinya Fujino

2.3 框架要做到良好的 Tree-Shaking
Tree-Shaking は簡単には dead code を取り除くことであり、ES Module の静的解析を利用して実現する。

しかし、副作用の可能性があるコードを上手く取り除くことができない場合がある。その場合、手動で /*#__PURE__*/ というアノテーションを追加して、rollup.js や webpack に副作用がないことを伝えるというテクニックが役に立つ。

Shinya FujinoShinya Fujino

2.4 框架应该输出怎样的构建产物
dev と prod だけでなく、ランタイムの環境によっても出力内容を区別する必要がある。

まず、<script>src を指定する場合に対しては、IIFE 形式のファイル (vue.global.js) を用意する。

また、<script> から ESM として利用するために、vue.esm-browser.js というファイルも出力される。さらに、bundler から読み込まれる場合に対応するため、vue.esm-bundler.js というファイルも出力される。前者は __DEV__truefalse などリテラルに置き換えられるのに対し、後者は process.env.NODE_ENV !== 'production' へと置き換えられる。

さらに、SSR 時において Node.js 環境でも実行されることを想定し、cjs 形式でも出力する必要がある。

Shinya FujinoShinya Fujino

2.5 特性开关
使用する機能に応じて出力内容を変えることも重要である。たとえば、Vue のユーザーは、Options API を完全に使用しない場合において、__VUE_OPTIONS_API__ を通じてその機能を落とし、関連するコードをパッケージに含めないようにすることが可能となっている。

Shinya FujinoShinya Fujino

2.6 错误处理
エラーハンドリングの良し悪しもフレームワークの良し悪しに直結する。ユーザーに統一的なエラーハンドリング用インターフェースを用意するとよい。Vue においては、callWithErrorHandlingapp.config.errorHandler などが用意されている。

Shinya FujinoShinya Fujino

2.7 良好的 TypeScript 类型支持
TS によりフレームワークが作成されていることと、TS の型サポートが優れていることとはまったく別のことであり、後者を達成するためには多くの労力を必要とする。たとえば、Vue のソースコード runtime-core/src/apiDefineComponent.ts は、実際に実行されるコードは 3 行だけだが、型サポートのために 200 近くの行数となっている。

Shinya FujinoShinya Fujino

第 3 章 Vue.js 3 的设计思路

Shinya FujinoShinya Fujino

3.1 声明式地描述 UI
UI を宣言的に記述する方法として、テンプレートによる方式とオブジェクトによる方式が示される。前者は直観的だが、後者の方がより柔軟性がある。Vue において後者によりコンポーネントを記述するには h 関数を用いる:

import { h } from 'vue'

export default {
  render() {
    return h('h1', { onClick: handler }) // 仮想 DOM
  }
}
Shinya FujinoShinya Fujino

3.2 初识渲染器
Renderer は仮想 DOM を実 DOM へと変換することが示される:

Virtual DOM h('div', 'hello') -> renderer -> Real DOM

Renderer の役割は以下に要約される:

  • 要素を作成する
  • 作成した要素に属性やイベントをアタッチする
  • 子要素を処理する

仮想 DOM が

const vnode = {
  tag: 'div',
  props: {
    onClick: () => alert('hello')
  },
  children: 'click me'
}

であるとき、Renderer は以下のように実装できる:

function renderer(vnode, container) {
  // vnode.tag により DOM 要素を作成する
  const el = document.createElement(vnode.tag)

  // vnode.props を走査して属性やイベントを作成し DOM 要素にアタッチする
  for (const key in vnode.props) {
    if (/^on/.test(key)) {
      // key が on で始まっていればイベントである
      el.addEventListener(
        key.substr(2).toLowerCase(), // onClick ---> click
        vnode.props[key] // イベントハンドラ
      )
    }
  }

  // 子要素を処理する
  if (typeof vnode.children === 'string') {
    // children が文字列であれば要素のテキストノードとする
    el.appendChild(document.createTextNode(vnode.children))
  } else if (Array.isArray(vnode.children)) {
    // renderer 関数を再帰的に呼び出し、el をマウントポイントとして子ノードをレンダリングする
    vnode.children.forEach(child => renderer(child, el))
  }

  // 要素をマウントする
  container.appendChild(el)
}

ただし、仮想 DOM の diff を計算し更新する処理についてはさらなる考慮を必要とする。

Shinya FujinoShinya Fujino

3.3 组件的本质
コンポーネントとは仮想 DOM を閉じ込めたものである。関数で表わせば

const MyComponent = function () {
  return {
    tag: 'div',
    props: {
      onClick: () => alert('hello')
    },
    children: 'click me'
  }
}

となり、オブジェクトで表わせば

const MyComponent = {
  render() {
    return {
      tag: 'div',
      props: {
        onClick: () => alert('hello')
      },
      children: 'click me'
    }
  }
}

となる。上で示した renderer がこれらの値を受け取ることができるよう変更することも可能である。

Vue では、状態をもつコンポーネントはオブジェクトの形式で表現されている。

Shinya FujinoShinya Fujino

3.4 模板的工作原理
Compiler は、<template> の内容をコンパイルし、<script> において export されるオブジェクトに render 関数として追加する。つまり、

<div @click="handler">
  click me
</div>

render() {
  return h('div', { onClick: handler }, 'click me')
}

のようにコンパイルされる。

Shinya FujinoShinya Fujino

3.5 Vue.js 是各个模块组成的有机整体
Compiler は Renderer と強調し、フレームワークの性能を向上させることに努める。たとえば、動的に変更される可能性がある属性の存在を示すために、Vue では patchFlags という値を render 関数において埋め込んでいる。これにより Renderer はそうした値の存在を事前に知ることができ、不要な処理を省くことができる。

Shinya FujinoShinya Fujino

第 4 章 响应系统的作用与实现

リアクティビティに関する章、概論が終わりここからが本番か。

関連:

https://vuejs.org/guide/extras/reactivity-in-depth.html
https://www.vuemastery.com/courses/vue-3-reactivity/vue3-reactivity/

Shinya FujinoShinya Fujino

4.1 响应式数据与副作用函数
以下の副作用をもつ関数 effect について考える:

const obj = { text: 'hello world' }
function effect() {
   document.body.innerText = obj.text
}

関数 effect は、bodyinnerTextobj.text をセットする。このとき、

obj.text = 'hello vue3'

というコードによって副作用 effect が再実行されるようにできないか、というモチベーションが語られる。

Shinya FujinoShinya Fujino

4.2 响应式数据的基本实现
リアクティビティの実現の基本は、値の読み取りと書き込み操作をインターセプトし、副作用をもつ関数とリアクティブな値を関係付けることにある。具体的には、読み取り操作において副作用をもつ関数を bucket の中に保存し、書き込み操作において関数を bucket から取り出し実行する。

上の内容は以下のコードによって実現できる:

// 副作用をもつ関数を保存する bucket
const bucket = new Set()

// オリジナルのデータ
const data = { text: 'hello world' }
// データに対する Proxy
const obj = new Proxy(data, {
  // 読み取り操作に対するインターセプト
  get(target, key) {
    // 副作用をもつ関数 effect を bucket に登録する
    bucket.add(effect)
    // プロパティの値を返す
    return target[key]
  },
  // 書き込み操作に対するインターセプト
  set(target, key, newVal) {
    // プロパティの値をセットする
    target[key] = newVal
    // bucket から関数を取り出し実行する
    bucket.forEach(fn => fn())
  }
})

function effect() {
  document.body.innerText = obj.text
}
effect() // 読み取り操作
setTimeout(() => {
  obj.text = 'hello vue3' // 書き込み操作
}, 1000)

しかし、このコードでは effect をハードコードしている点などで柔軟性に欠ける。

Shinya FujinoShinya Fujino

4.3 设计一个完善的响应系统
上で effect をハードコードしていた点については、

let activeEffect
function effect(fn) {
  activeEffect = fn
  fn()
}

として、effect が副作用をもつ関数を受け取るようにし、bucket にセットする関数を activeEffect とすればよい。

しかし、この実装では obj.notExist のような任意の値への書き込みによっても副作用が発生してしまう。

Shinya FujinoShinya Fujino

この問題の原因は、副作用をもつ関数と対象となるオブジェクトのプロパティとが関連づけられていないことによる。よって、bucket のデータ構造を見直す必要がある。

具体的には、bucket を WeakMap として

  • target -> Map

という関係を形成する。そして、この Map において

  • target の key -> 対応する関数の Set

という関係を管理する。まとめると、WeakMap<target, Map<key, Set<effect>>> となる。

これをコードで記述すると以下のようになる:

const bucket = new WeakMap()

const data = { text: 'hello world' }
const obj = new Proxy(data, {
  get(target, key) {
    let depsMap = bucket.get(target)
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
      depsMap.set(key, (deps = new Set()))
    }
    deps.add(activeEffect)
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
  }
})
Shinya FujinoShinya Fujino

上で WeakMap を使用しているのは、キーとなるオブジェクトへの参照がなくなれば、それに対応するデータを取り出す必要もなくなり、よって GC によってそれらが削除されても構わないことによる。もし Map により定義すると、オブジェクトは GC によって回収されず、メモリリークを引き起こしてしまう。

Shinya FujinoShinya Fujino

Proxy 内で関数の登録と実行をおこなっている箇所を、それぞれ tracktrigger としてまとめておく:

const obj = new Proxy(data, {
  get(target, key) {
    track(target, key)
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    trigger(target, key)
  }
})

function track(target, key) {
  let depsMap = bucket.get(target)
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  deps.add(activeEffect)
}

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn())
}
Shinya FujinoShinya Fujino

4.4 分支切换与 cleanup

const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, { /* ... */ })

effect(function effectFn() {
  document.body.innerText = obj.ok ? obj.text : 'not'
})

のように obj.ok の値に応じて分岐する場合、このあと obj.okfalse となっても depsMap の状態は変化せず、よってその段階で obj.text = 'hello vue3' とすると effectFn が実行されてしまう。しかし、obj.text の値は document.body.innerText には影響しないため、関数が実行されなくなることが望ましい。

Shinya FujinoShinya Fujino

これを達成するために、副作用をもつ関数の実行のたびに、その関数を各 Set から削除する。これは、関数自身に「自分がどの Set に含まれているか」という情報を付帯することで可能となる。

コードによって表現すると以下のようになる:

let activeEffect
function effect(fn) {
  const effectFn = () => {
    activeEffect = effectFn
    fn()
  }
  // この関数と関連する Set を保存するための配列を用意する
  effectFn.deps = []
  effectFn()
}

function track(target, key) {
  let depsMap = bucket.get(target)
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  deps.add(activeEffect)
  activeEffect.deps.push(deps) // 追加: deps を追加する
}
Shinya FujinoShinya Fujino

あとは、副作用をもつ関数の実行時に、関連する Set から関数を削除するよう変更すればよい:

let activeEffect
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn) // 追加
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = []
  effectFn()
}

function cleanup(effectFn) {
  // 各 Set を走査する
  for (let i = 0; i < effectFn.deps.length; i++) {
    // effectFn を要素とする Set
    const deps = effectFn.deps[i]
    // effectFn を Set から削除する
    deps.delete(effectFn)
  }
  // effectFn.deps をクリアする
  effectFn.deps.length = 0
}
Shinya FujinoShinya Fujino

ただし、この時点でコードを実行すると無限ループが発生する。原因は trigger 関数にある:

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn()) // 問題となる行
}

trigger の最終行において effects に対して走査しているが、これを実行する過程で effects への追加と削除がおこなわれる。これは

const set = new Set([1])

set.forEach(item => {
  set.delete(1)
  set.add(1)
  console.log('loop')
})

という処理が無限ループとなることと同じである。よって、新しい Set を用意し、それに対して forEach を実行する必要がある:

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  const effectsToRun = new Set(effects) // 追加
  effectsToRun.forEach(effectFn => effectFn()) // 追加
  // effects && effects.forEach(fn => fn())
}
Shinya FujinoShinya Fujino

4.5 嵌套的 effect 与 effect 栈
Vue の内部においてレンダリングは effect 内で実行される。コンポーネントはネストされることが普通であるため、effect もネストに対応する必要があるが、この時点での実装では、activeEffect の値が常にネストされた内部の effectFn を指してしまう。

この問題を解決するためにeffectStack を導入する:

let activeEffect
// effect スタック
const effectStack = []
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn) // 追加
    fn()
    effectStack.pop() // 追加
    activeEffect = effectStack[effectStack.length - 1] // 追加
  }
  effectFn.deps = []
  effectFn()
}
Shinya FujinoShinya Fujino

4.6 避免无限递归循环
次のコードは無限ループとなる:

const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(()=> {
  obj.foo = obj.foo + 1
})

なぜなら、obj.foo の読み取り操作により track が呼び出され、そして obj.foo への書き込み操作により trigger が呼び出されるため、effect に与えた関数の呼び出し中に同じ関数がまた呼び出されるという挙動となるからである。

これを避けるために、trigger において実行対象となる effectFnactiveEffect とは異なるようにする:

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => effectFn())
}
Shinya FujinoShinya Fujino

4.7 调度执行
副作用をもつ関数の再実行のタイミングや回数を指定するためのスケジューリング機能の追加をおこなう。具体的には、

effect(
  () => {
    console.log(obj.foo)
  },
  // options
  {
    scheduler(fn) {
      // ...
    }
  }
)

のように scheduler というオプションを通じてスケジューラーを指定できるようにする。

Shinya FujinoShinya Fujino

具体的には、effect

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.options = options // 追加
  effectFn.deps = []
  effectFn()
}

とし、trigger において scheduler が存在する場合はそれに実行を委ねるようにする:

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  // 以下を更新
  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}
Shinya FujinoShinya Fujino

4.8 计算属性 computed 与 lazy
この時点で、Vue の computed を実現するための準備がほぼ完成している。

しかし、その前に最後の準備をおこなう。以下のように、effect に与える関数を遅延実行できるようにし、またその関数を getter と見做して値を返せるようにする:

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    const res = fn() // 追加
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
    return res // 追加
  }
  effectFn.options = options
  effectFn.deps = []
  if (!options.lazy) { // 追加
    effectFn()
  }
  return effectFn // 追加
}

これにより、副作用をもつ関数の返り値が利用可能となった:

const effectFn = effect(
  () => obj.foo + obj.bar,
  { lazy: true }
)
const value = effectFn()
Shinya FujinoShinya Fujino

上の effect 関数を用いて computed を実装すると以下のようになる:

function computed(getter) {
  const effectFn = effect(getter, {
    lazy: true
  })
  const obj = {
    get value() {
      return effectFn()
    }
  }
  return obj
}

これは以下のように使用できる:

const data = { foo: 1, bar: 2 }
const obj = new Proxy(data, { /* ... */ })

const sumRes = computed(() => obj.foo + obj.bar)

console.log(sumRes.value) // 3
Shinya FujinoShinya Fujino

上の computed の実装は、値を取り出すたびに同じ計算を実行するため、無駄がある。これを改善するために、計算の実行が必要かどうかを示すフラグを追加する:

function computed(getter) {
  let value // 追加
  let dirty = true // 追加
  const effectFn = effect(getter, {
    lazy: true
  })
  const obj = {
    get value() {
      if (dirty) { // 追加
        value = effectFn()
        dirty = false
      }
      return value
    }
  }
  return obj
}

しかしこのままでは、一度値を取り出すと value が更新されなくなってしまう。

Shinya FujinoShinya Fujino

この問題を解決するには、追跡されているプロパティの更新の際に dirtytrue とすればよい。そのためには scheduler オプションを利用する:

function computed(getter) {
  let value
  let dirty = true
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() { // 追加
      dirty = true
    }
  })
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }
  return obj
}

使用例:

const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value) // 3
obj.foo++
console.log(sumRes.value) // 4
Shinya FujinoShinya Fujino

computed の値を effect の中で読み込む場合について考える。たとえば template において computed を使ったとき、その値の変化に応じて再レンダリングされることが期待されるが、現在の実装では期待通りの挙動とならない:

const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
  console.log(sumRes.value)
})
obj.foo++ // effect に与えた関数は再実行されない
Shinya FujinoShinya Fujino

この問題を解決するには、tracktrigger を手動で実行してやればよい:

function computed(getter) {
  let value
  let dirty = true
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true
      trigger(obj, 'value') // 追加
    }
  })
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      track(obj, 'value') // 追加
      return value
    }
  }
  return obj
}
Shinya FujinoShinya Fujino

4.9 watch 的实现原理
computed と同様に watcheffect を用いて実現可能である。

リアクティブなオブジェクト objfoo というプロパティをもつとする。foo の更新時にコールバックが呼ばれるためには、単に scheduler にてコールバックを呼び出せばよい:

function watch(source, cb) {
  effect(
    // 読み取り操作を実行し、副作用を登録しておく
    () => source.foo,
    {
      scheduler() {
        cb()
      }
    }
  }
}

これにより以下のようなコードが動作するようになる:

const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })

watch(obj, () => {
  console.log('データが変化しました')
})

obj.foo++
Shinya FujinoShinya Fujino

上で foo をハードコードしていたが、任意のリアクティブなオブジェクトに対応するために traverse 関数を実装して再帰的に読み取り操作をおこなうようにする:

function watch(source, cb) {
  effect(
    () => traverse(source), // 変更
    {
      scheduler() {
        cb()
      }
    }
  }
}

// 追加
function traverse(value, seen = new Set()) {
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  seen.add(value)
  for (const k in value) {
    traverse(value[k], seen)
  }
  return value
}
Shinya FujinoShinya Fujino

Vue の watch と同様に、第一引数に getter 関数を受け取れるよう拡張する:

function watch(source, cb) {
  // 追加
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  effect(
    () => getter(), // 変更
    {
      scheduler() {
        cb()
      }
    }
  }
}
Shinya FujinoShinya Fujino

コールバック関数において、新旧の値を利用できるように拡張する:

function watch(source, cb) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  let oldValue, newValue // 追加
  const effectFn = effect(
    () => getter(),
    {
      lazy: true, // 追加
      scheduler() {
        // 変更
        newValue = effectFn()
        cb(newValue, oldValue)
        oldValue = newValue
      }
    }
  }
  oldValue = effectFn()
}

これにより、以下のようなコードが動作するようになる:

watch(
  () => obj.foo,
  (newValue, oldValue) => {
    console.log(newValue, oldValue) // 2, 1
  }
)
obj.foo++
Shinya FujinoShinya Fujino

4.10 立即执行的 watch 与回调执行时机
immediate オプションを watch に追加し、コールバックを即時実行可能とする:

function watch(source, cb) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  let oldValue, newValue

  // 追加
  const job = () => {
    newValue = effectFn()
    cb(newValue, oldValue)
    oldValue = newValue
  }

  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler: job // 変更
    }
  }

  // 変更
  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}
Shinya FujinoShinya Fujino

flush: 'post' オプションをサポートするよう、さらに拡張する:

function watch(source, cb) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  let oldValue, newValue

  const job = () => {
    newValue = effectFn()
    cb(newValue, oldValue)
    oldValue = newValue
  }

  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        // 変更
        if (options.flush === 'post') {
          const p = Promise.resolve()
          p.then(job)
        } else {
          job()
        }
    }
  }

  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}
Shinya FujinoShinya Fujino

4.11 过期的副作用

let finalData

watch(obj, async () => {
  const res = await fetch('/path/to/request')
  finalData = res
}

というコードについて考える。もし obj のプロパティが連続して更新されたとすると、二つのリクエストが同時に走ることとなり、finalData にセットされる値が race condition により不定となる。

最初に実行された副作用は、その処理が終了する前に二つ目の副作用が実行されたことから、stale な状態になったと考えることができる。つまり、二つ目のリクエストがその時点における求める最新の結果であり、これが finalData にセットされるべきであると考える。

Shinya FujinoShinya Fujino

上の挙動を実現するために、コールバックの実行において次の実行の直前に呼ばれる関数を登録できるようにしておき、そこで現在のコールバックがすでに stale であることをセットするようにする。これにより、現在のコールバックが終了する前に次のコールバックが呼ばれた際、現在のコールバックを無効とすることができる:

function watch(source, cb) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  let oldValue, newValue

  // 追加
  let cleanup
  function onInvalidate(fn) {
    cleanup = fn
  }

  const job = () => {
    newValue = effectFn()
    if (cleanup) {  // 追加
      cleanup()
    }
    cb(newValue, oldValue)
    oldValue = newValue
  }

  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        if (options.flush === 'post') {
          const p = Promise.resolve()
          p.then(job)
        } else {
          job()
        }
    }
  }

  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}

あとは次のように stale でない場合は何もしないようにコールバックを定義すればよい:

watch(obj, async (newValue, oldValue, onInvalidate) => {
  let expired = false
  onInvalidate(() => {
    expired = true
  })
  const res = await fetch('/path/to/request')
  if (!expired) {
    finalData = res
  }
})