Skip to content

Commit

Permalink
feat(runtime-core): add watchEffect API
Browse files Browse the repository at this point in the history
BREAKING CHANGE: replae `watch(fn, options?)` with `watchEffect`

    The `watch(fn, options?)` signature has been replaced by the new
    `watchEffect` API, which has the same usage and behavior. `watch`
    now only supports the `watch(source, cb, options?)` signautre.
  • Loading branch information
yyx990803 committed Feb 22, 2020
1 parent b36a76f commit 99a2e18
Show file tree
Hide file tree
Showing 8 changed files with 77 additions and 31 deletions.
6 changes: 3 additions & 3 deletions packages/runtime-core/__tests__/apiSetupContext.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
render,
serializeInner,
nextTick,
watch,
watchEffect,
defineComponent,
triggerEvent,
TestElement
Expand Down Expand Up @@ -55,7 +55,7 @@ describe('api: setup context', () => {

const Child = defineComponent({
setup(props: { count: number }) {
watch(() => {
watchEffect(() => {
dummy = props.count
})
return () => h('div', props.count)
Expand Down Expand Up @@ -88,7 +88,7 @@ describe('api: setup context', () => {
},

setup(props) {
watch(() => {
watchEffect(() => {
dummy = props.count
})
return () => h('div', props.count)
Expand Down
57 changes: 43 additions & 14 deletions packages/runtime-core/__tests__/apiWatch.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { watch, reactive, computed, nextTick, ref, h } from '../src/index'
import {
watch,
watchEffect,
reactive,
computed,
nextTick,
ref,
h
} from '../src/index'
import { render, nodeOps, serializeInner } from '@vue/runtime-test'
import {
ITERATE_KEY,
Expand All @@ -13,10 +21,10 @@ import { mockWarn } from '@vue/shared'
describe('api: watch', () => {
mockWarn()

it('watch(effect)', async () => {
it('effect', async () => {
const state = reactive({ count: 0 })
let dummy
watch(() => {
watchEffect(() => {
dummy = state.count
})
expect(dummy).toBe(0)
Expand Down Expand Up @@ -117,10 +125,10 @@ describe('api: watch', () => {
expect(dummy).toMatchObject([[2, true], [1, false]])
})

it('stopping the watcher', async () => {
it('stopping the watcher (effect)', async () => {
const state = reactive({ count: 0 })
let dummy
const stop = watch(() => {
const stop = watchEffect(() => {
dummy = state.count
})
expect(dummy).toBe(0)
Expand All @@ -132,11 +140,32 @@ describe('api: watch', () => {
expect(dummy).toBe(0)
})

it('stopping the watcher (with source)', async () => {
const state = reactive({ count: 0 })
let dummy
const stop = watch(
() => state.count,
count => {
dummy = count
}
)

state.count++
await nextTick()
expect(dummy).toBe(1)

stop()
state.count++
await nextTick()
// should not update
expect(dummy).toBe(1)
})

it('cleanup registration (effect)', async () => {
const state = reactive({ count: 0 })
const cleanup = jest.fn()
let dummy
const stop = watch(onCleanup => {
const stop = watchEffect(onCleanup => {
onCleanup(cleanup)
dummy = state.count
})
Expand Down Expand Up @@ -187,7 +216,7 @@ describe('api: watch', () => {

const Comp = {
setup() {
watch(() => {
watchEffect(() => {
assertion(count.value)
})
return () => count.value
Expand Down Expand Up @@ -221,7 +250,7 @@ describe('api: watch', () => {

const Comp = {
setup() {
watch(
watchEffect(
() => {
assertion(count.value, count2.value)
},
Expand Down Expand Up @@ -263,7 +292,7 @@ describe('api: watch', () => {

const Comp = {
setup() {
watch(
watchEffect(
() => {
assertion(count.value)
},
Expand Down Expand Up @@ -363,14 +392,14 @@ describe('api: watch', () => {
expect(spy).toHaveBeenCalledTimes(3)
})

it('warn immediate option when using effect signature', async () => {
it('warn immediate option when using effect', async () => {
const count = ref(0)
let dummy
// @ts-ignore
watch(
watchEffect(
() => {
dummy = count.value
},
// @ts-ignore
{ immediate: false }
)
expect(dummy).toBe(0)
Expand All @@ -388,7 +417,7 @@ describe('api: watch', () => {
events.push(e)
})
const obj = reactive({ foo: 1, bar: 2 })
watch(
watchEffect(
() => {
dummy = [obj.foo, 'bar' in obj, Object.keys(obj)]
},
Expand Down Expand Up @@ -423,7 +452,7 @@ describe('api: watch', () => {
events.push(e)
})
const obj = reactive({ foo: 1 })
watch(
watchEffect(
() => {
dummy = obj.foo
},
Expand Down
5 changes: 3 additions & 2 deletions packages/runtime-core/__tests__/components/Suspense.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
nextTick,
onMounted,
watch,
watchEffect,
onUnmounted,
onErrorCaptured
} from '@vue/runtime-test'
Expand Down Expand Up @@ -163,7 +164,7 @@ describe('Suspense', () => {
// extra tick needed for Node 12+
deps.push(p.then(() => Promise.resolve()))

watch(() => {
watchEffect(() => {
calls.push('immediate effect')
})

Expand Down Expand Up @@ -265,7 +266,7 @@ describe('Suspense', () => {
const p = new Promise(r => setTimeout(r, 1))
deps.push(p)

watch(() => {
watchEffect(() => {
calls.push('immediate effect')
})

Expand Down
15 changes: 8 additions & 7 deletions packages/runtime-core/__tests__/errorHandling.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
watch,
ref,
nextTick,
defineComponent
defineComponent,
watchEffect
} from '@vue/runtime-test'
import { setErrorRecovery } from '../src/errorHandling'
import { mockWarn } from '@vue/shared'
Expand Down Expand Up @@ -241,7 +242,7 @@ describe('error handling', () => {
expect(fn).toHaveBeenCalledWith(err, 'ref function')
})

test('in watch (effect)', () => {
test('in effect', () => {
const err = new Error('foo')
const fn = jest.fn()

Expand All @@ -257,7 +258,7 @@ describe('error handling', () => {

const Child = {
setup() {
watch(() => {
watchEffect(() => {
throw err
})
return () => null
Expand All @@ -268,7 +269,7 @@ describe('error handling', () => {
expect(fn).toHaveBeenCalledWith(err, 'watcher callback')
})

test('in watch (getter)', () => {
test('in watch getter', () => {
const err = new Error('foo')
const fn = jest.fn()

Expand Down Expand Up @@ -298,7 +299,7 @@ describe('error handling', () => {
expect(fn).toHaveBeenCalledWith(err, 'watcher getter')
})

test('in watch (callback)', async () => {
test('in watch callback', async () => {
const err = new Error('foo')
const fn = jest.fn()

Expand Down Expand Up @@ -332,7 +333,7 @@ describe('error handling', () => {
expect(fn).toHaveBeenCalledWith(err, 'watcher callback')
})

test('in watch cleanup', async () => {
test('in effect cleanup', async () => {
const err = new Error('foo')
const count = ref(0)
const fn = jest.fn()
Expand All @@ -349,7 +350,7 @@ describe('error handling', () => {

const Child = {
setup() {
watch(onCleanup => {
watchEffect(onCleanup => {
count.value
onCleanup(() => {
throw err
Expand Down
15 changes: 15 additions & 0 deletions packages/runtime-core/src/apiWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export type StopHandle = () => void

const invoke = (fn: Function) => fn()

// Simple effect.
export function watchEffect(
effect: WatchEffect,
options?: BaseWatchOptions
): StopHandle {
return doWatch(effect, null, options)
}

// initial value for watchers to trigger on undefined initial values
const INITIAL_WATCHER_VALUE = {}

Expand Down Expand Up @@ -110,6 +118,13 @@ export function watch<T = any>(
// watch(source, cb)
return doWatch(effectOrSource, cbOrOptions, options)
} else {
// TODO remove this in the next release
__DEV__ &&
warn(
`\`watch(fn, options?)\` signature has been moved to a separate API. ` +
`Use \`watchEffect(fn, options?)\` instead. \`watch\` will only ` +
`support \`watch(source, cb, options?) signature in the next release.`
)
// watch(effect)
return doWatch(effectOrSource, null, cbOrOptions)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export {
markNonReactive
} from '@vue/reactivity'
export { computed } from './apiComputed'
export { watch } from './apiWatch'
export { watch, watchEffect } from './apiWatch'
export {
onBeforeMount,
onMounted,
Expand Down
4 changes: 2 additions & 2 deletions packages/vue/examples/composition/commits.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ <h1>Latest Vue.js Commits</h1>
</div>

<script>
const { createApp, ref, watch } = Vue
const { createApp, ref, watchEffect } = Vue
const API_URL = `https://api.github.com/repos/vuejs/vue-next/commits?per_page=3&sha=`

const truncate = v => {
Expand All @@ -37,7 +37,7 @@ <h1>Latest Vue.js Commits</h1>
const currentBranch = ref('master')
const commits = ref(null)

watch(() => {
watchEffect(() => {
fetch(`${API_URL}${currentBranch.value}`)
.then(res => res.json())
.then(data => {
Expand Down
4 changes: 2 additions & 2 deletions packages/vue/examples/composition/todomvc.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ <h1>todos</h1>
</div>

<script>
const { createApp, reactive, computed, watch, onMounted, onUnmounted } = Vue
const { createApp, reactive, computed, watchEffect, onMounted, onUnmounted } = Vue

const STORAGE_KEY = 'todos-vuejs-3.x'
const todoStorage = {
Expand Down Expand Up @@ -119,7 +119,7 @@ <h1>todos</h1>
})
})

watch(() => {
watchEffect(() => {
todoStorage.save(state.todos)
})

Expand Down

4 comments on commit 99a2e18

@jods4
Copy link
Contributor

@jods4 jods4 commented on 99a2e18 Feb 22, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yyx990803 What motivated this change?

From a user perspective I think watch(fn, options?) is the most useful signature of the two, so I'm a bit sad to see it get the longer name watchEffect.

Thanks to the beauty of automatic dependency tracking, you tend to just write the code that you want to run automatically and not care about its deps.

Want to paint a canvas? Just write the function that does the painting and call watchEffect(paint). It will repaint automatically whenever anything changes.
Want to fetch some server data? Just write the fetch based on your parameters and watchEffect(fetch). It will fetch any time some parameter changes.
Want to accept promises as well as raw data in your props? There you go:

setup(props) {
    const state = reactive({ loading: false, data: null });
    watchEffect(async () => {
      state.loading = true;
      state.data = await props.data;
      state.loading = false;
    });
    return state;
}

Need your own virtual scrolling? I did and this is the core piece:

  watchEffect(() => {
    // all the things I depend on
    const length = data.value.length;
    const { buffer, height, rowHeight, scrollTop } = state;
    // output values
    const index = state.index = Math.max((scrollTop / rowHeight | 0) - buffer, 0);
    const count = state.count = Math.min((height / rowHeight | 0) + 1 + buffer + buffer, length - index);
    state.topGap = index * rowHeight;
    state.bottomGap = (length - count - index) * rowHeight;
  });

Bottom line: watch(source, cb, options?) is the lesser used one because it makes you manually track your dependencies.
It's still useful for that special case where you want to depend on something you won't actually read in the callback; or when you access some reactive state that you don't want to depend on. But it's not the easiest api to use, most of the time watchEffect is.

@exodusanto
Copy link

@exodusanto exodusanto commented on 99a2e18 Feb 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I use more the watch with source than the current watchEffect.
For example to track loading status and then run animations or run external library update when a set of variables are updated.
And I think the code is more readable like watch(myVariable, then () => {})

@yyx990803
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jods4 There are various concerns with this change. One of the most obvious one is that from Vue 2's perspective, watch is an existing API and watchEffect is something new. Let's say we go the other way around, and have watch (eager) + watchSource (lazy), then we are:

  • Moving 2.x watch to a different name (this would affect the watch option in the Options API too).
  • Having a new API taking the watch name, but with a different behavior.
  • Incur a mental overhead for users who were used to the 2.x watch usage and what it means semantically. This is my primary concern, and we've already seen this from current 2.x users.

Whereas with watchEffect (eager) + watch (lazy):

  • The only change from 2.x is the addition of watchEffect.

I played with the shorter name effect, but it has several problems:

  • It's not a verb and can be too abstract for beginners.
  • effect alone only implies it applies side effects; however it doesn't signify that the effect will be reactively re-run.
  • Internally, the two APIs do share most of the logic and are closely connected. effect doesn't show that connection and can make it harder to 2.x users to grasp the concept.

With type definitions and proper IDE support, you will get auto completion for the API imports in both JS and TS, so name length shouldn't really be a concern (unless you really wish to stick to an editor without such support).

I did consider having both under the same watch with different signatures, but the eager/lazy behavior difference based on signature change can be confusing.

@jods4
Copy link
Contributor

@jods4 jods4 commented on 99a2e18 Feb 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see.
Everybody has a different mental model of how things work but I agree it's a bit confusing that effect runs only after the deps change in the first case, while they run every time in the second.
In that sense re-using watch in the new name is a little counter-productive.

I wish we had a better name but I don't have great suggestions... run, keepUp, sustain? meh.
I'll stop the bike-shedding here 🚲

Please sign in to comment.