『Vue.js设计与实现』読書メモ
積読していた本書を二、三日前に読み始めた(物理)。タイトルを和訳すると「Vue.js の設計と実装」のようになるだろうか。
よくある技術書は「ある技術の使い方」を学ぶことに重点が置かれているが、これは「技術の作り方」、特に Vue.js を主軸としてフロントエンドライブラリの設計や実装について解説している。しかも、解説しているのは Vue.js のコアチームメンバーの HcySunYang であるため、中の人によるライブラリの設計解説書籍という、あまり他に例がないものになっている。
あとで簡単に概略を追えるように、ここにメモを残していく。
書籍のサイト:
書籍のコード:
(なお、自分の中国語力は英語と比較してかなり劣るため、読み通せる自信はない...)
第 1 章 权衡的艺术
タイトルは「利害得失を比較して判断する技術」というような意味。ライブラリやフレームワークを作成する際、前提として多くの点について様々な角度から比較・検討しなければならないということがまず述べられる。そして具体的に以下のテーマが例として論じられる:
- Imperative vs Declarative
- Performance vs Maintainability
- 仮想 DOM の性能について
- Runtime vs Compile Time
1.1 命令式和声明式
Imperative に UI を記述する場合、求める結果に至るまでの過程を記述し、一方、Declarative に記述する場合、求める結果そのものを描くという、基本が説明される。Vue は内部で結果に至る過程を肩代わりしている。
1.2 性能与可维护性的权衡
性能に着目した場合、Declarative なコードが Imperative なコードに勝ることはない。なぜなら、UI を更新するコストを A、差分を計算するコストを B としたとき、
- Imperative なコードのコスト = A
- Declarative なコードのコスト = B + A
となるため。一方、保守性という観点では Declarative なコードが勝る。
よって、Declarative な UI の記述を可能とするライブラリを作成する場合、そのライブラリを使用したコードの保守性が高まるとともに、性能面での劣化ができる限り低減されるよう意識する必要がある。
1.3 虚拟 DOM 的性能到底如何
- ページの生成コスト
- 仮想 DOM
- オブジェクトの計算 (VNode)
- すべての DOM 要素の生成
- innerHTML
- HTML 文字列の計算
- すべての DOM 要素の生成
- 仮想 DOM
であり、仮想 DOM と innerHTML によるページ生成コストはそれほど大きな差はない (「オブジェクトの計算」と「HTML 文字列の計算」は、DOM の操作に対して十分に小さいとみなす)。一方、
- ページの更新コスト
- 仮想 DOM
- 新しいオブジェクトの計算
- Diff
- 更新が必要な DOM のみ更新する
- innerHTML
- HTML 文字列の計算
- 既存の DOM の廃棄
- 新しい DOM の生成
- 仮想 DOM
となり、更新に関しては仮想 DOM に軍配が上がる。これらを総合すると、パフォーマンスに関して
- innerHTML < 仮想 DOM
であり、これと document.createElement などによる DOM 操作が理論上最速であることを考慮すると、パフォーマンスに関して
- innerHTML < 仮想 DOM < ネイティブの DOM 操作
という不等式が成立する。
ただし、コードの保守性や読解に関する心理的負担も併せて考慮すると、仮想 DOM は優れたオプションといえる。
1.4 运行时和编译时
ランタイムとコンパイルタイムのどちらに処理を寄せるかについても考察が必要であることが語られる。Vue は両者を備えており、コンパイルによる最適化とランタイムの処理を保持することによる柔軟性の維持を目指している。
第 2 章 框架设计的核心
「フレームワーク設計におけるコア要素」として、以下の論点について議論される:
- 開発者体験の向上
- パッケージサイズの調整
- Tree-Shaking
- ビルド出力
- 特定機能の有効化/無効化
- エラーハンドリング
- TypeScript サポート
2.1 提升用户的开发体验
適切なエラーメッセージを提供することにより、ユーザーが問題を迅速に発見する一助となる。開発者体験は、フレームワークの優劣の指標の一つといえる。
2.2 控制框架代码的体积
開発者体験を高めるための機能が増えることで、フレームワークのコード量が増えてしまうが、これはクライアントがダウンロード・実行するコードの量を増やすことにつながる。これを避けるために、__DEV__
などの定数を用いて開発時の機能を分岐の中に入れ、__DEV__
が false
の際はその機能が dead code となるようにすることで、開発用とプロダクション用で異なるコードを出力する、というアイデアが示される。
2.3 框架要做到良好的 Tree-Shaking
Tree-Shaking は簡単には dead code を取り除くことであり、ES Module の静的解析を利用して実現する。
しかし、副作用の可能性があるコードを上手く取り除くことができない場合がある。その場合、手動で /*#__PURE__*/
というアノテーションを追加して、rollup.js や webpack に副作用がないことを伝えるというテクニックが役に立つ。
2.4 框架应该输出怎样的构建产物
dev と prod だけでなく、ランタイムの環境によっても出力内容を区別する必要がある。
まず、<script>
で src
を指定する場合に対しては、IIFE 形式のファイル (vue.global.js) を用意する。
また、<script>
から ESM として利用するために、vue.esm-browser.js というファイルも出力される。さらに、bundler から読み込まれる場合に対応するため、vue.esm-bundler.js というファイルも出力される。前者は __DEV__
が true
や false
などリテラルに置き換えられるのに対し、後者は process.env.NODE_ENV !== 'production'
へと置き換えられる。
さらに、SSR 時において Node.js 環境でも実行されることを想定し、cjs 形式でも出力する必要がある。
2.5 特性开关
使用する機能に応じて出力内容を変えることも重要である。たとえば、Vue のユーザーは、Options API を完全に使用しない場合において、__VUE_OPTIONS_API__
を通じてその機能を落とし、関連するコードをパッケージに含めないようにすることが可能となっている。
2.6 错误处理
エラーハンドリングの良し悪しもフレームワークの良し悪しに直結する。ユーザーに統一的なエラーハンドリング用インターフェースを用意するとよい。Vue においては、callWithErrorHandling
や app.config.errorHandler
などが用意されている。
2.7 良好的 TypeScript 类型支持
TS によりフレームワークが作成されていることと、TS の型サポートが優れていることとはまったく別のことであり、後者を達成するためには多くの労力を必要とする。たとえば、Vue のソースコード runtime-core/src/apiDefineComponent.ts は、実際に実行されるコードは 3 行だけだが、型サポートのために 200 近くの行数となっている。
第 3 章 Vue.js 3 的设计思路
3.1 声明式地描述 UI
UI を宣言的に記述する方法として、テンプレートによる方式とオブジェクトによる方式が示される。前者は直観的だが、後者の方がより柔軟性がある。Vue において後者によりコンポーネントを記述するには h
関数を用いる:
import { h } from 'vue'
export default {
render() {
return h('h1', { onClick: handler }) // 仮想 DOM
}
}
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 を計算し更新する処理についてはさらなる考慮を必要とする。
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 では、状態をもつコンポーネントはオブジェクトの形式で表現されている。
3.4 模板的工作原理
Compiler は、<template>
の内容をコンパイルし、<script>
において export
されるオブジェクトに render
関数として追加する。つまり、
<div @click="handler">
click me
</div>
は
render() {
return h('div', { onClick: handler }, 'click me')
}
のようにコンパイルされる。
3.5 Vue.js 是各个模块组成的有机整体
Compiler は Renderer と強調し、フレームワークの性能を向上させることに努める。たとえば、動的に変更される可能性がある属性の存在を示すために、Vue では patchFlags
という値を render
関数において埋め込んでいる。これにより Renderer はそうした値の存在を事前に知ることができ、不要な処理を省くことができる。
第 4 章 响应系统的作用与实现
リアクティビティに関する章、概論が終わりここからが本番か。
関連:
4.1 响应式数据与副作用函数
以下の副作用をもつ関数 effect
について考える:
const obj = { text: 'hello world' }
function effect() {
document.body.innerText = obj.text
}
関数 effect
は、body
の innerText
に obj.text
をセットする。このとき、
obj.text = 'hello vue3'
というコードによって副作用 effect
が再実行されるようにできないか、というモチベーションが語られる。
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
をハードコードしている点などで柔軟性に欠ける。
4.3 设计一个完善的响应系统
上で effect
をハードコードしていた点については、
let activeEffect
function effect(fn) {
activeEffect = fn
fn()
}
として、effect
が副作用をもつ関数を受け取るようにし、bucket
にセットする関数を activeEffect
とすればよい。
しかし、この実装では obj.notExist
のような任意の値への書き込みによっても副作用が発生してしまう。
この問題の原因は、副作用をもつ関数と対象となるオブジェクトのプロパティとが関連づけられていないことによる。よって、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())
}
})
上で WeakMap を使用しているのは、キーとなるオブジェクトへの参照がなくなれば、それに対応するデータを取り出す必要もなくなり、よって GC によってそれらが削除されても構わないことによる。もし Map により定義すると、オブジェクトは GC によって回収されず、メモリリークを引き起こしてしまう。
Proxy 内で関数の登録と実行をおこなっている箇所を、それぞれ track
と trigger
としてまとめておく:
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())
}
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.ok
が false
となっても depsMap
の状態は変化せず、よってその段階で obj.text = 'hello vue3'
とすると effectFn
が実行されてしまう。しかし、obj.text
の値は document.body.innerText
には影響しないため、関数が実行されなくなることが望ましい。
これを達成するために、副作用をもつ関数の実行のたびに、その関数を各 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 を追加する
}
あとは、副作用をもつ関数の実行時に、関連する 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
}
ただし、この時点でコードを実行すると無限ループが発生する。原因は 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())
}
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()
}
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
において実行対象となる effectFn
が activeEffect
とは異なるようにする:
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())
}
4.7 调度执行
副作用をもつ関数の再実行のタイミングや回数を指定するためのスケジューリング機能の追加をおこなう。具体的には、
effect(
() => {
console.log(obj.foo)
},
// options
{
scheduler(fn) {
// ...
}
}
)
のように scheduler
というオプションを通じてスケジューラーを指定できるようにする。
具体的には、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()
}
})
}
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()
上の 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
上の 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
が更新されなくなってしまう。
この問題を解決するには、追跡されているプロパティの更新の際に dirty
を true
とすればよい。そのためには 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
computed
の値を effect
の中で読み込む場合について考える。たとえば template において computed
を使ったとき、その値の変化に応じて再レンダリングされることが期待されるが、現在の実装では期待通りの挙動とならない:
const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
console.log(sumRes.value)
})
obj.foo++ // effect に与えた関数は再実行されない
この問題を解決するには、track
と trigger
を手動で実行してやればよい:
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
}
4.9 watch 的实现原理
computed
と同様に watch
も effect
を用いて実現可能である。
リアクティブなオブジェクト obj
が foo
というプロパティをもつとする。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++
上で 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
}
Vue の watch
と同様に、第一引数に getter 関数を受け取れるよう拡張する:
function watch(source, cb) {
// 追加
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
effect(
() => getter(), // 変更
{
scheduler() {
cb()
}
}
}
}
コールバック関数において、新旧の値を利用できるように拡張する:
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++
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()
}
}
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()
}
}
4.11 过期的副作用
let finalData
watch(obj, async () => {
const res = await fetch('/path/to/request')
finalData = res
}
というコードについて考える。もし obj
のプロパティが連続して更新されたとすると、二つのリクエストが同時に走ることとなり、finalData
にセットされる値が race condition により不定となる。
最初に実行された副作用は、その処理が終了する前に二つ目の副作用が実行されたことから、stale な状態になったと考えることができる。つまり、二つ目のリクエストがその時点における求める最新の結果であり、これが finalData
にセットされるべきであると考える。
上の挙動を実現するために、コールバックの実行において次の実行の直前に呼ばれる関数を登録できるようにしておき、そこで現在のコールバックがすでに 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
}
})
第 5 章 非原始值的响应式方案