diff --git a/packages/inula/__tests__/HorizonXTest/ReactiveTest/render.test.tsx b/packages/inula/__tests__/HorizonXTest/ReactiveTest/render.test.tsx new file mode 100644 index 00000000..5d82a087 --- /dev/null +++ b/packages/inula/__tests__/HorizonXTest/ReactiveTest/render.test.tsx @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { render, act, useReactive, useReference, useComputed, useWatch } from '../../../src'; +import { Text, triggerClickEvent } from '../../jest/commonComponents'; +import * as Inula from '../../../src'; + +describe('test reactive in FunctionComponent', () => { + const { unmountComponentAtNode } = Inula; + let container: HTMLElement | null = null; + beforeEach(() => { + // 创建一个 DOM 元素作为渲染目标 + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + // 退出时进行清理 + unmountComponentAtNode(container); + container?.remove(); + container = null; + }); + + it('should support useReactive in FunctionComponent', () => { + const fn = jest.fn(); + + function App(props) { + fn(); + + const reactiveObj = useReactive({ + persons: [ + { name: 'p1', age: 1 }, + { name: 'p2', age: 2 }, + ], + }); + + const newPerson = { name: 'p3', age: 3 }; + const addOnePerson = function () { + reactiveObj.persons.push(newPerson); + }; + const delOnePerson = function () { + reactiveObj.persons.pop(); + }; + + return ( +
+ + + +
+ ); + } + + render(, container); + + expect(container?.querySelector('#hasPerson')?.innerHTML).toBe('has new person: 2'); + act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container?.querySelector('#hasPerson')?.innerHTML).toBe('has new person: 3'); + + act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container?.querySelector('#hasPerson')?.innerHTML).toBe('has new person: 2'); + + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('should support ref object in FunctionComponent', () => { + const fn = jest.fn(); + function App(props) { + fn(); + const refObj = useReference({ + persons: [ + { name: 'p1', age: 1 }, + { name: 'p2', age: 2 }, + ], + }); + + const newPerson = { name: 'p3', age: 3 }; + const addOnePerson = function () { + refObj.value.persons.push(newPerson); + }; + const delOnePerson = function () { + refObj.value.persons.pop(); + }; + + return ( +
+ + + +
+ ); + } + + render(, container); + + expect(container?.querySelector('#hasPerson')?.innerHTML).toBe('has new person: 2'); + act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container?.querySelector('#hasPerson')?.innerHTML).toBe('has new person: 3'); + + act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container?.querySelector('#hasPerson')?.innerHTML).toBe('has new person: 2'); + + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('should support ref primitive in FunctionComponent', () => { + const fn = jest.fn(); + function App(props) { + fn(); + const refObj = useReference(2); + + const add = function () { + refObj.value++; + }; + const del = function () { + refObj.value--; + }; + + return ( +
+ + + +
+ ); + } + + render(, container); + + expect(container?.querySelector('#hasPerson')?.innerHTML).toBe('has new person: 2'); + // 在Array中增加一个对象 + act(() => { + triggerClickEvent(container, 'addBtn'); + }); + expect(container?.querySelector('#hasPerson')?.innerHTML).toBe('has new person: 3'); + + act(() => { + triggerClickEvent(container, 'delBtn'); + }); + expect(container?.querySelector('#hasPerson')?.innerHTML).toBe('has new person: 2'); + + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('should support useComputed in FunctionComponent', () => { + const fn = jest.fn(); + function App(props) { + const data = useReactive<{ bar?: string }>({}); + const computedData = useComputed(() => { + fn(); + return data.bar; + }); + + const setText = function () { + data.bar = 'bar'; + }; + + return ( +
+ + +
+ ); + } + + render(, container); + + expect(container?.querySelector('#text')?.innerHTML).toBe(''); + act(() => { + triggerClickEvent(container, 'setText'); + }); + expect(container?.querySelector('#text')?.innerHTML).toBe('bar'); + + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should support useWatch in FunctionComponent', () => { + const fn = jest.fn(); + function App(props) { + let dummy; + const counter = useReactive({ num: 0 }); + useWatch(() => { + fn(); + dummy = counter.num; + }); + + const updateCounter = function () { + counter.num++; + }; + + return ( +
+ + +
+ ); + } + + render(, container); + + expect(container?.querySelector('#text')?.innerHTML).toBe('0'); + act(() => { + triggerClickEvent(container, 'updateCounter'); + }); + expect(container?.querySelector('#text')?.innerHTML).toBe('1'); + + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/inula/__tests__/HorizonXTest/ReactiveTest/watch.test.tsx b/packages/inula/__tests__/HorizonXTest/ReactiveTest/watch.test.tsx new file mode 100644 index 00000000..e9084017 --- /dev/null +++ b/packages/inula/__tests__/HorizonXTest/ReactiveTest/watch.test.tsx @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { ref, reactive, watch, computed } from '../../../src'; + +describe('test watch', () => { + it('should watch effect', async () => { + const state = reactive({ count: 0 }); + let dummy; + watch(() => { + dummy = state.count; + }); + expect(dummy).toBe(0); + + state.count++; + expect(dummy).toBe(1); + }); + + it('should watching single source: getter', async () => { + const state = reactive({ count: 0 }); + let dummy; + watch( + () => state.count, + (count, prevCount) => { + dummy = [count, prevCount]; + // assert types + count + 1; + if (prevCount) { + prevCount + 1; + } + } + ); + state.count++; + expect(dummy).toMatchObject([1, 0]); + }); + + it('should watching single source: ref', async () => { + const count = ref(0); + let dummy; + const spy = jest.fn(); + watch(count, (count, prevCount) => { + spy(); + dummy = [count, prevCount]; + }); + count.value++; + expect(dummy).toMatchObject([1, 0]); + expect(spy).toBeCalledTimes(1); + }); + + it('watching single source: array', async () => { + const array = reactive([]); + const spy = jest.fn((val, prevVal) => { + let a = 1; + }); + watch(array, spy); + array.push(1); + + // push会触发两次spy(一次是push,一次是length) + expect(spy).toBeCalledTimes(2); + }); + + it('should not fire if watched getter result did not change', async () => { + const spy = jest.fn(); + const n = ref(0); + watch(() => n.value % 2, spy); + + n.value++; + expect(spy).toBeCalledTimes(1); + + n.value += 2; + // should not be called again because getter result did not change + expect(spy).toBeCalledTimes(1); + }); + + it('watching single source: computed ref', async () => { + const count = ref(0); + const plus = computed(() => count.value + 1); + let dummy; + watch(plus, (count, prevCount) => { + dummy = [count, prevCount]; + // assert types + count + 1; + if (prevCount) { + prevCount + 1; + } + }); + count.value++; + expect(dummy).toMatchObject([2, 1]); + }); + + it('watching primitive with deep: true', async () => { + const count = ref(0); + let dummy; + watch(count, (c, prevCount) => { + dummy = [c, prevCount]; + }); + count.value++; + expect(dummy).toMatchObject([1, 0]); + }); + + it('directly watching reactive object (with automatic deep: true)', async () => { + const src = reactive({ + count: 0, + }); + let dummy; + watch(src, ({ count }) => { + dummy = count; + }); + src.count++; + expect(dummy).toBe(1); + }); + + it('directly watching reactive object with explicit deep: true', async () => { + const src = reactive({ + state: { + count: 0, + }, + }); + let dummy; + watch(src, ({ state }) => { + dummy = state?.count; + }); + + // nested should not trigger + src.state.count++; + expect(dummy).toBe(1); + + // root level should trigger + src.state = { count: 2 }; + expect(dummy).toBe(2); + }); + + it('watching multiple sources', async () => { + const spy = jest.fn(); + const state = reactive({ count: 1 }); + const count = ref(1); + const plus = computed(() => count.value + 1); + + let dummy; + watch([() => state.count, count, plus], (vals, oldVals) => { + spy(); + dummy = [vals, oldVals]; + // assert types + vals.concat(1); + oldVals.concat(1); + }); + + state.count++; + expect(dummy).toMatchObject([ + [2, 1, 2], + [1, 1, 2], + ]); + expect(spy).toBeCalledTimes(1); + + count.value++; + // count触发一次,plus触发一次 + expect(spy).toBeCalledTimes(3); + }); + + it('watching multiple sources: readonly array', async () => { + const state = reactive({ count: 1 }); + const status = ref(false); + + let dummy; + watch([() => state.count, status] as const, (vals, oldVals) => { + dummy = [vals, oldVals]; + const [count] = vals; + const [, oldStatus] = oldVals; + // assert types + count + 1; + oldStatus === true; + }); + + state.count++; + expect(dummy).toMatchObject([ + [2, false], + [1, false], + ]); + status.value = true; + expect(dummy).toMatchObject([ + [2, true], + [2, false], + ]); + }); + + it('watching multiple sources: reactive object (with automatic deep: true)', async () => { + const src = reactive({ count: 0 }); + let dummy; + watch([src], ([state]) => { + dummy = state; + // assert types + state.count === 1; + }); + src.count++; + expect(dummy).toMatchObject({ count: 1 }); + }); + + it('stopping the watcher (effect)', async () => { + const state = reactive({ count: 0 }); + let dummy; + const stop = watch(() => { + dummy = state.count; + }); + expect(dummy).toBe(0); + + stop(); + state.count++; + // should not update + 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++; + expect(dummy).toBe(1); + + stop(); + state.count++; + // should not update + expect(dummy).toBe(1); + }); + + it('deep watch effect', async () => { + const state = reactive({ + nested: { + count: 0, + }, + array: [1, 2, 3], + map: new Map([ + ['a', 1], + ['b', 2], + ]), + set: new Set([1, 2, 3]), + }); + + let dummy; + watch(() => { + dummy = [state.nested.count, state.array[0], state.map.get('a'), state.set.has(1)]; + }); + + state.nested.count++; + expect(dummy).toEqual([1, 1, 1, true]); + + // nested array mutation + state.array[0] = 2; + expect(dummy).toEqual([1, 2, 1, true]); + + // nested map mutation + state.map.set('a', 2); + expect(dummy).toEqual([1, 2, 2, true]); + + // nested set mutation + state.set.delete(1); + expect(dummy).toEqual([1, 2, 2, false]); + }); + + it('watching deep ref', async () => { + const count = ref(0); + const double = computed(() => count.value * 2); + const state = reactive([count, double]); + + let dummy; + watch(() => { + dummy = [state[0].value, state[1].value]; + }); + + count.value++; + expect(dummy).toEqual([1, 2]); + }); + + it('warn and not respect deep option when using effect', async () => { + const arr = ref([1, [2]]); + const spy = jest.fn(); + watch(() => { + spy(); + return arr; + }); + expect(spy).toHaveBeenCalledTimes(1); + (arr.value[1] as Array)[0] = 3; + expect(spy).toHaveBeenCalledTimes(1); + // expect(`"deep" option is only respected`).toHaveBeenWarned() + }); + + test('watchEffect should not recursively trigger itself', async () => { + const spy = jest.fn(); + const price = ref(10); + const history = ref([]); + watch(() => { + history.value.push(price.value); + spy(); + }); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('computed refs should not trigger watch if value has no change', async () => { + const spy = jest.fn(); + const source = ref(0); + const price = computed(() => source.value === 0); + watch(price, spy); + source.value++; + source.value++; + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('watching multiple sources: computed', async () => { + let count = 0; + const value = ref('1'); + const plus = computed(() => !!value.value); + watch([plus], () => { + count++; + }); + value.value = '2'; + expect(plus.value).toBe(true); + expect(count).toBe(0); + }); +}); diff --git a/packages/inula/__tests__/HorizonXTest/ReactiveTest/watchEffect.test.tsx b/packages/inula/__tests__/HorizonXTest/ReactiveTest/watchEffect.test.tsx index 4616009b..1ed81541 100644 --- a/packages/inula/__tests__/HorizonXTest/ReactiveTest/watchEffect.test.tsx +++ b/packages/inula/__tests__/HorizonXTest/ReactiveTest/watchEffect.test.tsx @@ -21,7 +21,7 @@ function stop(stopHandle: () => void) { describe('test watchEffect', () => { it('should run the passed function once (wrapped by a effect)', () => { - const fnSpy = jest.fn(() => {}); + const fnSpy = jest.fn(); watchEffect(fnSpy); expect(fnSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/inula/__tests__/HorizonXTest/reactive/reactive.test.tsx b/packages/inula/__tests__/HorizonXTest/reactive/reactive.test.tsx deleted file mode 100644 index 7d61dbb8..00000000 --- a/packages/inula/__tests__/HorizonXTest/reactive/reactive.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (c) 2024 Huawei Technologies Co.,Ltd. - * - * openInula is licensed under Mulan PSL v2. - * You can use this software according to the terms and conditions of the Mulan PSL v2. - * You may obtain a copy of Mulan PSL v2 at: - * - * http://license.coscl.org.cn/MulanPSL2 - * - * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, - * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, - * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. - * See the Mulan PSL v2 for more details. - */ - -import { reactive, watchEffect } from '../../../src/index'; - -describe('reactive', () => { - it('should trigger when delete array item', () => { - const fn = jest.fn(); - const arr = reactive([1, 2, 3]); - watchEffect(() => { - fn(); - arr.length; - }); - expect(arr.length).toBe(3); - - // 用shift删除数组元素,代码: - arr.shift(); - - // Even if the array element is deleted, the array length will not change - expect(arr.length).toBe(2); - expect(fn).toHaveBeenCalledTimes(2); - }); -}); diff --git a/packages/inula/src/index.ts b/packages/inula/src/index.ts index a802bc60..54fc09b5 100644 --- a/packages/inula/src/index.ts +++ b/packages/inula/src/index.ts @@ -56,11 +56,11 @@ import { isPortal, } from './external/InulaIs'; import { createStore, useStore, clearStore } from './inulax/store/StoreHandler'; -import { reactive, toRaw } from './inulax/reactive/Reactive'; -import { ref, isRef, unref, shallowRef } from './inulax/reactive/Ref'; +import { reactive, useReactive, toRaw } from './inulax/reactive/Reactive'; +import { ref, useReference, isRef, unref, shallowRef } from './inulax/reactive/Ref'; import * as reduxAdapter from './inulax/adapters/redux'; -import { watch, watchEffect } from './inulax/reactive/Watch'; -import { computed } from './inulax/reactive/Computed'; +import { watch, watchEffect, useWatch } from './inulax/reactive/Watch'; +import { computed, useComputed } from './inulax/reactive/Computed'; import { act } from './external/TestUtil'; import { @@ -127,14 +127,18 @@ const Inula = { Suspense, // vue reactive api ref, + useReference, isRef, unref, shallowRef, reactive, + useReactive, isReactive, isShallow, computed, + useComputed, watchEffect, + useWatch, toRaw, }; @@ -194,14 +198,18 @@ export { Suspense, // vue reactive api ref, + useReference, isRef, unref, shallowRef, reactive, + useReactive, isReactive, isShallow, computed, + useComputed, watchEffect, + useWatch, toRaw, }; diff --git a/packages/inula/src/inulax/CommonUtils.ts b/packages/inula/src/inulax/CommonUtils.ts index 64a0fe96..956e6ed9 100644 --- a/packages/inula/src/inulax/CommonUtils.ts +++ b/packages/inula/src/inulax/CommonUtils.ts @@ -14,6 +14,7 @@ */ import { KeyTypes, ReactiveFlags } from './Constants'; +import { Mutation } from './types/ProxyTypes'; export function isObject(obj: any): boolean { const type = typeof obj; @@ -133,21 +134,24 @@ export function getDetailedType(val: any) { return typeof val; } -export function resolveMutation(from, to) { +export function resolveMutation( + from: T, + to: T +): Mutation { if (getDetailedType(from) !== getDetailedType(to)) { return { mutation: true, from, to }; } switch (getDetailedType(from)) { case 'array': { - const len = Math.max(from.length, to.length); + const len = Math.max(from.length ?? 0, to.length ?? 0); const res: any[] = []; let found = false; for (let i = 0; i < len; i++) { - if (from.length <= i) { + if ((from.length ?? 0) <= i) { res[i] = { mutation: true, to: to[i] }; found = true; - } else if (to.length <= i) { + } else if ((to.length ?? 0) <= i) { res[i] = { mutation: true, from: from[i] }; found = true; } else { diff --git a/packages/inula/src/inulax/docs/reactive_feature.md b/packages/inula/src/inulax/docs/reactive_feature.md index bfff3176..20bd99ea 100644 --- a/packages/inula/src/inulax/docs/reactive_feature.md +++ b/packages/inula/src/inulax/docs/reactive_feature.md @@ -185,3 +185,58 @@ it('should no longer update when stopped', () => { expect(dummy).toBe(1); }); ``` + +watch接口差异: +1、不支持deep,可以只传一个函数,那样会自动跟踪。 +```js +it('deep', async () => { + const state = reactive({ + nested: { + count: ref(0), + }, + array: [1, 2, 3], + map: new Map([ + ['a', 1], + ['b', 2], + ]), + set: new Set([1, 2, 3]), + }); + + let dummy; + watch( + () => state, + state => { + dummy = [ + state.nested.count, + state.array[0], + state.map.get('a'), + state.set.has(1), + ] + } + ) + + state.nested.count++; + expect(dummy).toEqual(undefined); + + // 改成: + watch( + () => { + dummy = [ + state.nested.count, + state.array[0], + state.map.get('a'), + state.set.has(1), + ] + } + ) +}); +``` +2、不支持immediate。 +```js +it('immediate', async () => { + const count = ref(0); + const cb = vi.fn(); + watch(count, cb, { immediate: true }); + expect(cb).toHaveBeenCalledTimes(0); + }) +``` diff --git a/packages/inula/src/inulax/proxy/Observer.ts b/packages/inula/src/inulax/proxy/Observer.ts index 894dcd1a..40af77f5 100644 --- a/packages/inula/src/inulax/proxy/Observer.ts +++ b/packages/inula/src/inulax/proxy/Observer.ts @@ -20,7 +20,7 @@ import { devtools } from '../devtools'; import { KeyTypes } from '../Constants'; import { addRContext, RContextSet } from '../reactive/RContext'; -import { IObserver } from '../types/ProxyTypes'; +import { IObserver, Listener, Mutation } from '../types/ProxyTypes'; /** * 一个对象(对象、数组、集合)对应一个Observer @@ -30,7 +30,7 @@ export class Observer implements IObserver { keyVNodes = new Map(); - listeners: ((mutation) => void)[] = []; + listeners: Listener[] = []; watchers = {}; @@ -71,7 +71,7 @@ export class Observer implements IObserver { } // 对象的属性被赋值时调用 - setProp(key: string | symbol, mutation: any, oldValue?: any, newValue?: any): void { + setProp(key: string | symbol, mutation: Mutation, oldValue?: any, newValue?: any): void { const vNodes = this.keyVNodes.get(key); // NOTE: using Set directly can lead to deadlock const vNodeArray = Array.from(vNodes || []); @@ -115,11 +115,11 @@ export class Observer implements IObserver { launchUpdateFromVNode(vNode); } - addListener(listener: (mutation) => void): void { + addListener(listener: Listener): void { this.listeners.push(listener); } - removeListener(listener: (mutation) => void): void { + removeListener(listener: Listener): void { this.listeners = this.listeners.filter(item => item != listener); } diff --git a/packages/inula/src/inulax/proxy/ProxyHandler.ts b/packages/inula/src/inulax/proxy/ProxyHandler.ts index c5a84cda..5c0b2913 100644 --- a/packages/inula/src/inulax/proxy/ProxyHandler.ts +++ b/packages/inula/src/inulax/proxy/ProxyHandler.ts @@ -65,7 +65,7 @@ export function createProxy(rawObj: any, listener?: CurrentListener, isDeepProxy deepProxyMap.set(rawObj, isDeepProxy); // 创建Proxy - let proxyObj; + let proxyObj: ProxyHandler; if (!isDeepProxy) { proxyObj = createObjectProxy( rawObj, diff --git a/packages/inula/src/inulax/proxy/handlers/ArrayProxyHandler.ts b/packages/inula/src/inulax/proxy/handlers/ArrayProxyHandler.ts index fa14c917..4beee71c 100644 --- a/packages/inula/src/inulax/proxy/handlers/ArrayProxyHandler.ts +++ b/packages/inula/src/inulax/proxy/handlers/ArrayProxyHandler.ts @@ -15,9 +15,9 @@ import { registerListener } from './HandlerUtils'; import { baseSetFun, baseGetFun } from './BaseObjectHandler'; -import { CurrentListener, Listeners } from '../../types/ProxyTypes'; +import { CurrentListener, Listeners, ObjectType } from '../../types/ProxyTypes'; -export function createArrayProxy(rawObj: T, listener: CurrentListener) { +export function createArrayProxy(rawObj: T, listener: CurrentListener): ProxyHandler { const listeners: Listeners = []; function get(rawObj: T, key: KeyType, receiver: any) { @@ -31,5 +31,5 @@ export function createArrayProxy(rawObj: T, listener: CurrentLi registerListener(rawObj, listener, listeners); - return new Proxy(rawObj, handler); + return new Proxy(rawObj as ObjectType, handler); } diff --git a/packages/inula/src/inulax/proxy/handlers/BaseObjectHandler.ts b/packages/inula/src/inulax/proxy/handlers/BaseObjectHandler.ts index d5a7eb92..f01bb729 100644 --- a/packages/inula/src/inulax/proxy/handlers/BaseObjectHandler.ts +++ b/packages/inula/src/inulax/proxy/handlers/BaseObjectHandler.ts @@ -34,7 +34,7 @@ export function baseSetFun(rawObj: any[], key: string, value: any, receiver: any } const oldLength = isArr ? rawObj.length : 0; - const oldJSON = isPanelActive() ? JSON.parse(JSON.stringify(rawObj)) : null; + const oldObj = isPanelActive() ? JSON.parse(JSON.stringify(rawObj)) : null; const hadKey = isArr && isValidIntegerKey(key) ? Number(key) < rawObj.length : Object.prototype.hasOwnProperty.call(rawObj, key); @@ -45,7 +45,7 @@ export function baseSetFun(rawObj: any[], key: string, value: any, receiver: any const observer = getObserver(rawObj); if (!isSame(newValue, oldValue)) { - const mutation = isPanelActive() ? resolveMutation(oldJSON, rawObj) : resolveMutation(null, rawObj); + const mutation = resolveMutation(oldObj, rawObj); // 触发属性变化 observer.setProp(key, mutation, oldValue, newValue); @@ -145,14 +145,14 @@ export function has(rawObj: T, key: KeyType) { } export function deleteProperty(rawObj: T, key: KeyType) { - const oldObject = isPanelActive() ? JSON.parse(JSON.stringify(rawObj)) : null; + const oldObj = isPanelActive() ? JSON.parse(JSON.stringify(rawObj)) : null; const observer = getObserver(rawObj); const oldValue = rawObj[key]; const newValue = undefined; const ret = Reflect.deleteProperty(rawObj, key); - const mutation = isPanelActive() ? resolveMutation(oldObject, rawObj) : resolveMutation(null, rawObj); + const mutation = resolveMutation(oldObj, rawObj); if (!isSame(newValue, oldValue)) { observer.setProp(key, mutation, oldValue, newValue); diff --git a/packages/inula/src/inulax/proxy/handlers/MapProxy.ts b/packages/inula/src/inulax/proxy/handlers/MapProxy.ts index 1dd62606..a2af5c93 100644 --- a/packages/inula/src/inulax/proxy/handlers/MapProxy.ts +++ b/packages/inula/src/inulax/proxy/handlers/MapProxy.ts @@ -19,11 +19,11 @@ import { resolveMutation } from '../../CommonUtils'; import { KeyTypes } from '../../Constants'; import { getValOrProxy, registerListener } from './HandlerUtils'; import { baseDeleteFun, baseHasFun, baseForEach, baseGetFun, baseClearFun } from './BaseCollectionHandler'; -import { CurrentListener, Listeners } from '../../types/ProxyTypes'; +import { CurrentListener, Listeners, ObjectType } from '../../types/ProxyTypes'; type IteratorTypes = 'keys' | 'values' | 'entries'; -export function createMapProxy>(rawObj: T, listener: CurrentListener): Record { +export function createMapProxy>(rawObj: T, listener: CurrentListener): ProxyHandler { const listeners: Listeners = []; // 场景:let obj = {}; map.set(obj, val); // 满足两个UT:1、map.has(Array.from(map.keys())[0])为true; 2、map.has(obj)为true; @@ -198,5 +198,5 @@ export function createMapProxy>(rawObj: T, listener: Cur registerListener(rawObj, listener, listeners); - return new Proxy(rawObj, handler); + return new Proxy(rawObj as ObjectType, handler); } diff --git a/packages/inula/src/inulax/proxy/handlers/ObjectProxyHandler.ts b/packages/inula/src/inulax/proxy/handlers/ObjectProxyHandler.ts index ae2c77f0..dfb8eef5 100644 --- a/packages/inula/src/inulax/proxy/handlers/ObjectProxyHandler.ts +++ b/packages/inula/src/inulax/proxy/handlers/ObjectProxyHandler.ts @@ -17,7 +17,11 @@ import { registerListener } from './HandlerUtils'; import { baseSetFun, baseGetFun, has, deleteProperty, ownKeys } from './BaseObjectHandler'; import { CurrentListener, KeyType, Listeners, ObjectType } from '../../types/ProxyTypes'; -export function createObjectProxy(rawObj: T, listener: CurrentListener, singleLevel = false) { +export function createObjectProxy( + rawObj: T, + listener: CurrentListener, + singleLevel = false +): ProxyHandler { const listeners: Listeners = []; function get(rawObj: T, key: KeyType, receiver: any): any { diff --git a/packages/inula/src/inulax/proxy/handlers/SetProxy.ts b/packages/inula/src/inulax/proxy/handlers/SetProxy.ts index 3e368db0..331e9031 100644 --- a/packages/inula/src/inulax/proxy/handlers/SetProxy.ts +++ b/packages/inula/src/inulax/proxy/handlers/SetProxy.ts @@ -109,5 +109,5 @@ export function createSetProxy>(rawObj: T, listener: CurrentL registerListener(rawObj, listener, listeners); - return new Proxy(rawObj, handler as any); + return new Proxy(rawObj, handler); } diff --git a/packages/inula/src/inulax/proxy/handlers/WeakMapProxy.ts b/packages/inula/src/inulax/proxy/handlers/WeakMapProxy.ts index 0ad8fefa..bbc22501 100644 --- a/packages/inula/src/inulax/proxy/handlers/WeakMapProxy.ts +++ b/packages/inula/src/inulax/proxy/handlers/WeakMapProxy.ts @@ -18,10 +18,10 @@ import { isSame } from '../../CommonUtils'; import { resolveMutation } from '../../CommonUtils'; import { isPanelActive } from '../../devtools'; import { getValOrProxy, registerListener } from './HandlerUtils'; -import { CurrentListener, Listeners } from '../../types/ProxyTypes'; +import { CurrentListener, Listeners, ObjectType } from '../../types/ProxyTypes'; import { baseDeleteFun, baseGetFun } from './BaseCollectionHandler'; -export function createWeakMapProxy>(rawObj: T, listener: CurrentListener) { +export function createWeakMapProxy>(rawObj: T, listener: CurrentListener): ProxyHandler { const listeners: Listeners = []; const handler = { @@ -72,5 +72,5 @@ export function createWeakMapProxy>(rawObj: T, liste registerListener(rawObj, listener, listeners); - return new Proxy(rawObj, handler as any); + return new Proxy(rawObj as ObjectType, handler as any); } diff --git a/packages/inula/src/inulax/reactive/Computed.ts b/packages/inula/src/inulax/reactive/Computed.ts index 75241a96..7d6e719c 100644 --- a/packages/inula/src/inulax/reactive/Computed.ts +++ b/packages/inula/src/inulax/reactive/Computed.ts @@ -16,6 +16,10 @@ import { RContext } from './RContext'; import { Observer } from '../proxy/Observer'; import { isSame } from '../CommonUtils'; +import { useRef } from '../../renderer/hooks/HookExternal'; +import { RefType } from '../types/ReactiveTypes'; +import { KeyTypes } from '../Constants'; +import { Listener } from '../types/ProxyTypes'; export type ComputedFN = (oldValue?: T) => T; @@ -23,7 +27,16 @@ export function computed(fn: ComputedFN): ComputedImpl { return new ComputedImpl(fn); } -export class ComputedImpl { +export function useComputed(fn: ComputedFN): ComputedImpl { + const objRef = useRef(null); + if (objRef.current === null) { + objRef.current = new ComputedImpl(fn); + } + + return objRef.current; +} + +export class ComputedImpl { private _value: T; private readonly fn: ComputedFN; private readonly rContext: RContext; @@ -50,11 +63,19 @@ export class ComputedImpl { this._value = this.fn(oldValue); if (!isSame(oldValue, this._value)) { - this.observer.setProp('value', {}); + this.observer.setProp('value', { mutation: true, from: oldValue, to: this._value }); } } stop() { this.rContext.stop(); } + + [KeyTypes.ADD_LISTENER](listener: Listener) { + this.observer.addListener(listener); + } + + [KeyTypes.REMOVE_LISTENER](listener: Listener) { + this.observer.removeListener(listener); + } } diff --git a/packages/inula/src/inulax/reactive/Reactive.ts b/packages/inula/src/inulax/reactive/Reactive.ts index 544a334d..f24ad62c 100644 --- a/packages/inula/src/inulax/reactive/Reactive.ts +++ b/packages/inula/src/inulax/reactive/Reactive.ts @@ -17,12 +17,21 @@ import { createProxy } from '../proxy/ProxyHandler'; import { KeyTypes } from '../Constants'; import { ReactiveRet } from '../types/ReactiveTypes'; import { ObjectType } from '../types/ProxyTypes'; +import { registerDestroyFunction } from '../store/StoreHandler'; +import { useRef } from '../../renderer/hooks/HookExternal'; export function reactive(rawObj: T): ReactiveRet; export function reactive(rawObj: T) { return createProxy(rawObj); } +export function useReactive(rawObj: T): ReactiveRet; +export function useReactive(rawObj: T) { + registerDestroyFunction(); + const objRef = useRef(rawObj); + return createProxy(objRef.current); +} + export function toRaw(observed: T): T { const raw = observed && observed[KeyTypes.RAW_VALUE]; return raw ? toRaw(raw) : observed; diff --git a/packages/inula/src/inulax/reactive/Ref.ts b/packages/inula/src/inulax/reactive/Ref.ts index e22f62d7..00164fdd 100644 --- a/packages/inula/src/inulax/reactive/Ref.ts +++ b/packages/inula/src/inulax/reactive/Ref.ts @@ -16,8 +16,14 @@ import { isObject, isSame, isShallow } from '../CommonUtils'; import { reactive, toRaw } from './Reactive'; import { Observer } from '../proxy/Observer'; -import { OBSERVER_KEY } from '../Constants'; +import { KeyTypes, OBSERVER_KEY } from '../Constants'; import { MaybeRef, RefType, UnwrapRef } from '../types/ReactiveTypes'; +import { registerDestroyFunction } from '../store/StoreHandler'; +import { getProcessingVNode } from '../../renderer/GlobalVar'; +import { FunctionComponent } from '../../renderer/vnode/VNodeTags'; +import { useRef } from '../../renderer/hooks/HookExternal'; +import { createProxy } from '../proxy/ProxyHandler'; +import { Listener } from '../types/ProxyTypes'; export function ref(): RefType; export function ref(value: T): RefType>; @@ -25,10 +31,24 @@ export function ref(value?: unknown) { return createRef(value, false); } -function createRef(rawValue: unknown, isShallow: boolean) { +export function useReference(): RefType; +export function useReference(value: T): RefType>; +export function useReference(value?: unknown) { + registerDestroyFunction(); + + const objRef = useRef(null); + if (objRef.current === null) { + objRef.current = createRef(value, false); + } + + return objRef.current; +} + +function createRef(rawValue: unknown, isShallow: boolean): RefType { if (isRef(rawValue)) { return rawValue; } + return new RefImpl(rawValue, isShallow); } @@ -55,15 +75,24 @@ class RefImpl { const useDirectValue = this._isShallow || isShallow(newVal); newVal = useDirectValue ? newVal : toRaw(newVal); if (!isSame(newVal, this._rawValue)) { + const mutation = { mutation: true, from: this._rawValue, to: newVal }; this._rawValue = newVal; this._value = useDirectValue ? newVal : toReactive(newVal); - this.observer.setProp('value', {}); + this.observer.setProp('value', mutation); } } get [OBSERVER_KEY]() { return this.observer; } + + [KeyTypes.ADD_LISTENER](listener: Listener) { + this.observer.addListener(listener); + } + + [KeyTypes.REMOVE_LISTENER](listener: Listener) { + this.observer.removeListener(listener); + } } export function isRef(ref: MaybeRef): ref is RefType; @@ -72,7 +101,7 @@ export function isRef(ref: any): ref is RefType { } export function toReactive(value: T): T { - return isObject(value) ? reactive(value as Record) : value; + return isObject(value) ? createProxy(value) : value; } export function unref(ref: MaybeRef): T { diff --git a/packages/inula/src/inulax/reactive/Watch.ts b/packages/inula/src/inulax/reactive/Watch.ts index 86f1db09..5d861855 100644 --- a/packages/inula/src/inulax/reactive/Watch.ts +++ b/packages/inula/src/inulax/reactive/Watch.ts @@ -14,13 +14,70 @@ */ import { RContext } from './RContext'; +import { useRef } from '../../renderer/hooks/HookExternal'; +import { RefType } from '../types/ReactiveTypes'; +import { WatchCallback } from '../types/ProxyTypes'; +import { computed, ComputedImpl } from './Computed'; +import { isRef } from './Ref'; +import { isArray, isReactive } from '../CommonUtils'; +import { toRaw } from './Reactive'; -export function watch(stateVariable: any, listener: (state: any) => void) { - listener = listener.bind(null, stateVariable); - stateVariable.addListener(listener); +export type WatchSource = RefType | ProxyHandler | ComputedImpl | (() => T); + +export function watch(source: WatchSource | WatchSource[], fn: WatchCallback) { + if (isRef(source) || isReactive(source)) { + return doWatch(source, fn); + } else if (isArray(source)) { + const stops = (source as any[]).map((s, index) => { + return watch(s, (val, prevVal) => { + const vals = getSourcesValue(source); + const prevVals = getSourcesValue(source); + prevVals[index] = prevVal; + fn(vals, prevVals); + }); + }); + + return () => { + stops.forEach(stop => stop()); + }; + } else if (typeof source === 'function') { + if (fn) { + return doWatch(computed(source), fn); + } else { + // no cb -> simple effect + const rContext = new RContext(source); + + rContext.run(); + + return () => { + rContext.stop(); + }; + } + } +} + +function getSourcesValue(sources: WatchSource[]) { + return sources.map(source => { + if (isRef(source)) { + return source.value; + } else if (isReactive(source)) { + return toRaw(source); + } else if (typeof source === 'function') { + return source(); + } + }); +} + +function doWatch(source: WatchSource, listener: WatchCallback) { + let cb = (source: WatchSource, change) => { + const { mutation } = change; + listener(mutation.to, mutation.from); + }; + cb = cb.bind(null, source); + source.addListener(cb); return () => { - stateVariable.removeListener(listener); + source.removeListener(cb); }; } @@ -35,3 +92,12 @@ export function watchEffect(fn: () => void): any { }; } } + +export function useWatch(source: WatchSource | WatchSource[], fn: WatchCallback): any { + const objRef = useRef(null); + if (objRef.current === null) { + objRef.current = watch(source, fn); + } + + return objRef.current; +} diff --git a/packages/inula/src/inulax/store/StoreHandler.ts b/packages/inula/src/inulax/store/StoreHandler.ts index a9b9c7d4..2b30ecde 100644 --- a/packages/inula/src/inulax/store/StoreHandler.ts +++ b/packages/inula/src/inulax/store/StoreHandler.ts @@ -100,7 +100,7 @@ export function clearVNodeObservers(vNode: VNode) { } // 注册VNode销毁时的清理动作 -function registerDestroyFunction() { +export function registerDestroyFunction() { const processingVNode = getProcessingVNode(); // 获取不到当前运行的VNode,说明不在组件中运行,属于非法场景 @@ -174,7 +174,6 @@ export function createStore, A extends UserActions const storeObj = { id, $s: proxyObj, - $state: proxyObj, $a: $a as StoreActions, $c: $c as ComputedValues, $queue: $queue as QueuedStoreActions, diff --git a/packages/inula/src/inulax/types/ProxyTypes.ts b/packages/inula/src/inulax/types/ProxyTypes.ts index 751f14e2..d66593d1 100644 --- a/packages/inula/src/inulax/types/ProxyTypes.ts +++ b/packages/inula/src/inulax/types/ProxyTypes.ts @@ -29,6 +29,7 @@ export type Listeners = Listener[]; export type CurrentListener = { current: Listener }; export type WatchHandler = (key?: KeyType, oldValue?: any, newValue?: any, mutation?: any) => void; export type WatchFn = (prop: KeyType, handler?: WatchHandler) => void; +export type WatchCallback = (val: any, prevVal: any) => void; type WatchProp = T & { watch?: WatchFn }; export type AddWatchProp = @@ -67,3 +68,9 @@ export interface IObserver { clearByVNode: (vNode: any) => void; } + +export type Mutation = { + mutation: boolean; + from: T; + to: T; +};