From 93f45ebd35554de757ea0a5b7949f7197022969e Mon Sep 17 00:00:00 2001 From: "chenrongyan.cry" Date: Mon, 9 May 2022 14:51:48 +0800 Subject: [PATCH 1/6] feat: collection proxy --- examples/basic/main.js | 3 +- packages/pwc/package.json | 3 +- packages/pwc/src/constants.ts | 2 + .../__tests__/createReactive/Map.test.ts | 110 +++++++++ .../__tests__/createReactive/Set.test.ts | 80 +++++++ .../__tests__/createReactive/WeakMap.test.ts | 35 +++ .../__tests__/createReactive/WeakSet.test.ts | 30 +++ .../__tests__/createReactive/base.test.ts | 62 +++++ packages/pwc/src/reactivity/baseProxy.ts | 66 ++++++ .../pwc/src/reactivity/collectionProxy.ts | 216 ++++++++++++++++++ packages/pwc/src/reactivity/createReactive.ts | 44 ++++ packages/pwc/src/reactivity/handler.ts | 61 ----- packages/pwc/src/reactivity/reactive.ts | 14 +- packages/pwc/src/reactivity/track.ts | 61 +++++ packages/pwc/src/utils/checkTypes.ts | 7 + packages/pwc/src/utils/index.ts | 2 +- packages/pwc/src/utils/reactiveMethods.ts | 15 ++ packages/pwc/src/utils/shallowEqual.ts | 11 +- packages/pwc/src/utils/toRaw.ts | 6 - 19 files changed, 751 insertions(+), 77 deletions(-) create mode 100644 packages/pwc/src/reactivity/__tests__/createReactive/Map.test.ts create mode 100644 packages/pwc/src/reactivity/__tests__/createReactive/Set.test.ts create mode 100644 packages/pwc/src/reactivity/__tests__/createReactive/WeakMap.test.ts create mode 100644 packages/pwc/src/reactivity/__tests__/createReactive/WeakSet.test.ts create mode 100644 packages/pwc/src/reactivity/__tests__/createReactive/base.test.ts create mode 100644 packages/pwc/src/reactivity/baseProxy.ts create mode 100644 packages/pwc/src/reactivity/collectionProxy.ts create mode 100644 packages/pwc/src/reactivity/createReactive.ts delete mode 100644 packages/pwc/src/reactivity/handler.ts create mode 100644 packages/pwc/src/reactivity/track.ts create mode 100644 packages/pwc/src/utils/reactiveMethods.ts delete mode 100644 packages/pwc/src/utils/toRaw.ts diff --git a/examples/basic/main.js b/examples/basic/main.js index df831f3..92e059d 100644 --- a/examples/basic/main.js +++ b/examples/basic/main.js @@ -2,7 +2,8 @@ import { reactive, customElement, attribute, html } from 'pwc'; @customElement('child-element') class Child extends HTMLElement { - name = 'Child'; + @reactive + accessor data = {}; @reactive @attribute('data-class-name') diff --git a/packages/pwc/package.json b/packages/pwc/package.json index ebc3ef3..2cdf3d6 100644 --- a/packages/pwc/package.json +++ b/packages/pwc/package.json @@ -4,7 +4,8 @@ "author": "Rax Team", "homepage": "https://github.com/raxjs/pwc#readme", "license": "MIT", - "main": "esm/index.js", + "main": "cjs/index.js", + "module": "", "files": [ "esm/", "es2017/", diff --git a/packages/pwc/src/constants.ts b/packages/pwc/src/constants.ts index f72fbea..dcaf383 100644 --- a/packages/pwc/src/constants.ts +++ b/packages/pwc/src/constants.ts @@ -3,6 +3,8 @@ export const TEXT_COMMENT_DATA = '?pwc_t'; export const PLACEHOLDER_COMMENT_DATA = '?pwc_p'; export const enum ReactiveFlags { RAW = '__p_raw__', + PROPERTY = '__p_property__', + IS_REACTIVE = '__p_is_reactive__', } export const TemplateString = 'templateString'; diff --git a/packages/pwc/src/reactivity/__tests__/createReactive/Map.test.ts b/packages/pwc/src/reactivity/__tests__/createReactive/Map.test.ts new file mode 100644 index 0000000..96e460b --- /dev/null +++ b/packages/pwc/src/reactivity/__tests__/createReactive/Map.test.ts @@ -0,0 +1,110 @@ +import { createReactive } from '../../createReactive'; + +describe('createReactive/Map', () => { + const propName = 'prop'; + function foo() {} + it('instanceof', () => { + const mockCallback = jest.fn(foo); + const original = new Map(); + const reactived = createReactive(original, propName, mockCallback); + + expect(original instanceof Map).toBe(true); + expect(reactived instanceof Map).toBe(true); + }); + it('set/clear/delete should trigger handlers', () => { + const mockCallback = jest.fn(foo); + const original = new Map(); + const reactived = createReactive(original, propName, mockCallback); + + // set + reactived.set('a', { foo: 1 }); + reactived.set('b', { foo: 2 }); + expect(mockCallback.mock.calls.length).toBe(2); + + // delete + reactived.delete('a'); + expect(mockCallback.mock.calls.length).toBe(3); + + // clear + reactived.clear(); + expect(mockCallback.mock.calls.length).toBe(4); + }); + + it('should observe for "of"', () => { + const mockCallback = jest.fn(foo); + const original = new Map([ + [{ key: 'a' }, { value: 'a' }], + [{ key: 'b' }, { value: 'a' }], + ]); + const reactived = createReactive(original, propName, mockCallback); + + let index = 1; + for(let [key, value] of reactived) { + key.key += '!'; + expect(mockCallback.mock.calls.length).toBe(index++); + value.value += '!'; + expect(mockCallback.mock.calls.length).toBe(index++); + } + }); + + it('should observe for "forEach"', () => { + const mockCallback = jest.fn(foo); + const original = new Map([ + [{ key: 'a' }, { value: 'a' }], + [{ key: 'b' }, { value: 'a' }], + ]); + const reactived = createReactive(original, propName, mockCallback); + + let index = 1; + reactived.forEach((key, value) => { + key.key += '!'; + expect(mockCallback.mock.calls.length).toBe(index++); + value.value += '!'; + expect(mockCallback.mock.calls.length).toBe(index++); + }); + }); + + it('should observe for "keys"', () => { + const mockCallback = jest.fn(foo); + const original = new Map([ + [{ key: 'a' }, { value: 'a' }], + [{ key: 'b' }, { value: 'a' }], + ]); + const reactived = createReactive(original, propName, mockCallback); + + let index = 1; + const keys = reactived.keys(); + for (let key of keys) { + key.key += '!'; + expect(mockCallback.mock.calls.length).toBe(index++); + } + }); + + it('should observe for "values"', () => { + const mockCallback = jest.fn(foo); + const original = new Map([ + [{ key: 'a' }, { value: 'a' }], + [{ key: 'b' }, { value: 'a' }], + ]); + const reactived = createReactive(original, propName, mockCallback); + + let index = 1; + const values = reactived.values(); + for (let value of values) { + value.value += '!'; + expect(mockCallback.mock.calls.length).toBe(index++); + } + }); + + it('should observe for "get"', () => { + const mockCallback = jest.fn(foo); + const original = new Map([ + ['a', { value: 'a' }], + ]); + const reactived = createReactive(original, propName, mockCallback); + + const value = reactived.get('a'); + value.value = 'b'; + expect(mockCallback.mock.calls.length).toBe(1); + }); +}); \ No newline at end of file diff --git a/packages/pwc/src/reactivity/__tests__/createReactive/Set.test.ts b/packages/pwc/src/reactivity/__tests__/createReactive/Set.test.ts new file mode 100644 index 0000000..9010954 --- /dev/null +++ b/packages/pwc/src/reactivity/__tests__/createReactive/Set.test.ts @@ -0,0 +1,80 @@ +import { createReactive } from '../../createReactive'; + +describe('createReactive/Set', () => { + const propName = 'prop'; + function foo() {} + it('instanceof', () => { + const mockCallback = jest.fn(foo); + const original = new Set(); + const reactived = createReactive(original, propName, mockCallback); + + expect(original instanceof Set).toBe(true); + expect(reactived instanceof Set).toBe(true); + }); + it('add/clear/delete should trigger handlers', () => { + const mockCallback = jest.fn(foo); + const original = new Set(); + const reactived = createReactive(original, propName, mockCallback); + + // add + const a = { foo: 1 }; + const b = { foo: 2 }; + reactived.add(a); + reactived.add(b); + expect(mockCallback.mock.calls.length).toBe(2); + + // delete + reactived.delete(a); + expect(mockCallback.mock.calls.length).toBe(3); + + // clear + reactived.clear(); + expect(mockCallback.mock.calls.length).toBe(4); + }); + + it('should observe for "of"', () => { + const mockCallback = jest.fn(foo); + const original = new Set([ + { value: 'a' }, + { value: 'b' }, + ]); + const reactived = createReactive(original, propName, mockCallback); + + let index = 1; + for(let value of reactived) { + value.value += '!'; + expect(mockCallback.mock.calls.length).toBe(index++); + } + }); + + it('should observe for "forEach"', () => { + const mockCallback = jest.fn(foo); + const original = new Set([ + { value: 'a' }, + { value: 'b' }, + ]); + const reactived = createReactive(original, propName, mockCallback); + + let index = 1; + reactived.forEach((value) => { + value.value += '!'; + expect(mockCallback.mock.calls.length).toBe(index++); + }); + }); + + it('should observe for "values"', () => { + const mockCallback = jest.fn(foo); + const original = new Set([ + { value: 'a' }, + { value: 'b' }, + ]); + const reactived = createReactive(original, propName, mockCallback); + + let index = 1; + const values = reactived.values(); + for (let value of values) { + value.value += '!'; + expect(mockCallback.mock.calls.length).toBe(index++); + } + }); +}); \ No newline at end of file diff --git a/packages/pwc/src/reactivity/__tests__/createReactive/WeakMap.test.ts b/packages/pwc/src/reactivity/__tests__/createReactive/WeakMap.test.ts new file mode 100644 index 0000000..e725645 --- /dev/null +++ b/packages/pwc/src/reactivity/__tests__/createReactive/WeakMap.test.ts @@ -0,0 +1,35 @@ +import { createReactive } from '../../createReactive'; + +describe('createReactive/WeakMap', () => { + const propName = 'prop'; + function foo() {} + it('instanceof', () => { + const mockCallback = jest.fn(foo); + const original = new WeakMap(); + const reactived = createReactive(original, propName, mockCallback); + + expect(original instanceof WeakMap).toBe(true); + expect(reactived instanceof WeakMap).toBe(true); + }); + it('set/delete should trigger handlers', () => { + const mockCallback = jest.fn(foo); + const original = new WeakMap(); + const reactived = createReactive(original, propName, mockCallback); + + const a = { + key: 'a' + }; + const b = { + key: 'b' + }; + + // set + reactived.set(a, { foo: 1 }); + reactived.set(b, { foo: 2 }); + expect(mockCallback.mock.calls.length).toBe(2); + + // delete + reactived.delete(a); + expect(mockCallback.mock.calls.length).toBe(3); + }); +}); \ No newline at end of file diff --git a/packages/pwc/src/reactivity/__tests__/createReactive/WeakSet.test.ts b/packages/pwc/src/reactivity/__tests__/createReactive/WeakSet.test.ts new file mode 100644 index 0000000..bba7cff --- /dev/null +++ b/packages/pwc/src/reactivity/__tests__/createReactive/WeakSet.test.ts @@ -0,0 +1,30 @@ +import { createReactive } from '../../createReactive'; + +describe('createReactive/Set', () => { + const propName = 'prop'; + function foo() {} + it('instanceof', () => { + const mockCallback = jest.fn(foo); + const original = new WeakSet(); + const reactived = createReactive(original, propName, mockCallback); + + expect(original instanceof WeakSet).toBe(true); + expect(reactived instanceof WeakSet).toBe(true); + }); + it('add/clear/delete should trigger handlers', () => { + const mockCallback = jest.fn(foo); + const original = new WeakSet(); + const reactived = createReactive(original, propName, mockCallback); + + // add + const a = { foo: 1 }; + const b = { foo: 2 }; + reactived.add(a); + reactived.add(b); + expect(mockCallback.mock.calls.length).toBe(2); + + // delete + reactived.delete(a); + expect(mockCallback.mock.calls.length).toBe(3); + }); +}); \ No newline at end of file diff --git a/packages/pwc/src/reactivity/__tests__/createReactive/base.test.ts b/packages/pwc/src/reactivity/__tests__/createReactive/base.test.ts new file mode 100644 index 0000000..b5be9e3 --- /dev/null +++ b/packages/pwc/src/reactivity/__tests__/createReactive/base.test.ts @@ -0,0 +1,62 @@ +import { getProperties, toRaw } from '../../../utils'; +import { createReactive } from '../../createReactive'; + +describe('createReactive', () => { + const propName = 'prop'; + it('Obecjt', () => { + const mockCallback = jest.fn(() => {}); + const source = { foo: 1 }; + const reactived = createReactive(source, propName, mockCallback); + + reactived.foo = 2; + expect(mockCallback.mock.calls.length).toBe(1); + + // assign new object + reactived.newObj = { + foo: 1 + }; + expect(mockCallback.mock.calls.length).toBe(2); + + // nesting changed + reactived.newObj.foo = 2; + expect(mockCallback.mock.calls.length).toBe(3); + }); + it('Array', () => { + const mockCallback = jest.fn(() => {}); + const source: any[] = ['foo', { foo: 1 }]; + const reactived = createReactive(source, propName, mockCallback); + + reactived[0] = 'newItem'; + expect(mockCallback.mock.calls.length).toBe(1); + + // nesting changed + reactived[1].foo = 2; + expect(mockCallback.mock.calls.length).toBe(2); + + // methods + reactived.push({ foo: 2 }); + expect(mockCallback.mock.calls.length).toBe(3); + + // new item changed + reactived[2].foo = 3; + expect(mockCallback.mock.calls.length).toBe(4); + }); + + it('toRaw', () => { + const mockCallback = jest.fn(() => {}); + const source = { foo: 1 }; + const reactived = createReactive(source, propName, mockCallback); + + const raw = toRaw(reactived); + expect(raw).toBe(source); + }); + + it('getProperties', () => { + const mockCallback = jest.fn(() => {}); + const source = { obj: { foo: 1 } }; + const reactived = createReactive(source, propName, mockCallback); + + expect(getProperties(reactived)).toEqual(new Set([propName])); + expect(getProperties(reactived.obj)).toEqual(new Set([propName])); + }); +}); \ No newline at end of file diff --git a/packages/pwc/src/reactivity/baseProxy.ts b/packages/pwc/src/reactivity/baseProxy.ts new file mode 100644 index 0000000..d167999 --- /dev/null +++ b/packages/pwc/src/reactivity/baseProxy.ts @@ -0,0 +1,66 @@ +import { ReactiveFlags } from '../constants'; +import { hasOwnProperty, isArray, isObject, toRaw } from '../utils'; +import { toReactive } from './createReactive'; +import { forwardTracks, getPropertyNames, runHandlers } from './track'; + +function get( + target: any, + key: string, + receiver: any, +): any { + if (key === ReactiveFlags.PROPERTY) { + return getPropertyNames(target); + } + if (key === ReactiveFlags.RAW) { + return target; + } + const result = Reflect.get(target, key, receiver); + if (isObject(result)) { + forwardTracks(target, result); + return toReactive(result); + } + return result; +} + +function set( + target: any, + key: string, + value: unknown, + receiver: any, +) { + const result = Reflect.set(target, key, value, receiver); + const originTarget = toRaw(receiver); + + // Ignore the set which happened in a prototype chain + if (originTarget !== target) { + return result; + } + + // Ignore the array.length changes + if (!isArray(target) || key !== 'length') { + runHandlers(target); + } + + return result; +} + +function deleteProperty( + target: any, + key: string, +): boolean { + const hadKey = hasOwnProperty(target, key); + const result = Reflect.deleteProperty(target, key); + + if (result && hadKey) { + runHandlers(target); + } + return result; +} + +export function createBaseProxy(target: any) { + return new Proxy(target, { + get, + set, + deleteProperty, + }); +} \ No newline at end of file diff --git a/packages/pwc/src/reactivity/collectionProxy.ts b/packages/pwc/src/reactivity/collectionProxy.ts new file mode 100644 index 0000000..e2873a6 --- /dev/null +++ b/packages/pwc/src/reactivity/collectionProxy.ts @@ -0,0 +1,216 @@ +import { ReactiveFlags } from '../constants'; +import { hasOwnProperty, isObject, toRaw, isMap } from '../utils'; +import { toReactive } from './createReactive'; +import { forwardTracks, getPropertyNames, runHandlers } from './track'; + +export type CollectionTypes = IterableCollections | WeakCollections; + +type IterableCollections = Map | Set; +type WeakCollections = WeakMap | WeakSet; +type MapTypes = Map | WeakMap; +type SetTypes = Set | WeakSet; + +interface Iterable { + [Symbol.iterator](): Iterator; +} + +interface Iterator { + next(value?: any): IterationResult; +} + +interface IterationResult { + value: any; + done: boolean; +} + +function get( + this: MapTypes, + key: unknown, +) { + const raw = toRaw(this); + const rawKey = toRaw(key); + + const result = raw.get(rawKey); + if (result && isObject(result)) { + forwardTracks(raw, result); + return toReactive(result); + } + return result; +} + +function has( + this: CollectionTypes, + key: unknown, +) { + const raw = toRaw(this); + const rawKey = toRaw(key); + + return raw.has(rawKey); +} + +function size( + target: IterableCollections, +) { + const raw = toRaw(target); + return raw.size; +} + +function set( + this: MapTypes, + key: unknown, + value: unknown, +) { + const raw = toRaw(this); + const rawKey = toRaw(key); + const rawValue = toRaw(value); + + const hadKey = raw.has(rawKey); + const isEqual = raw.get(rawKey) === rawValue; + if (!hadKey || !isEqual) { + raw.set(rawKey, rawValue); + runHandlers(raw); + } + + return this; +} + +function add( + this: SetTypes, + value: unknown, +) { + const raw = toRaw(this); + const hadKey = raw.has(value); + if (!hadKey) { + raw.add(value); + runHandlers(raw); + } + return this; +} + +function deleteItem( + this: CollectionTypes, + key: unknown, +) { + const raw = toRaw(this); + const rawKey = toRaw(key); + + const hadKey = raw.has(rawKey); + + const result = raw.delete(key); + if (hadKey) { + runHandlers(raw); + } + return result; +} + +function clear( + this: IterableCollections, +) { + const raw = toRaw(this); + const hadItems = raw.size > 0; + const result = raw.clear(); + if (hadItems) { + runHandlers(raw); + } + return result; +} + +function forEach( + this: IterableCollections, + callback: Function, + thisArg?: unknown, +) { + const raw = toRaw(this); + return raw.forEach((value, key) => { + forwardTracks(raw, value); + forwardTracks(raw, key); + return callback.call(thisArg, toReactive(value), toReactive(key), this); + }); +} + +function createIterableMethod( + method: string | symbol, +) { + return function ( + this: IterableCollections, + ...args: unknown[] + ): Iterable & Iterator { + const raw = toRaw(this); + const innerIterator = raw[method](...args); + const targetIsMap = isMap(raw); + const isPair = + method === 'entries' || (method === Symbol.iterator && targetIsMap); + return { + next() { + const { value, done } = innerIterator.next(); + if (done) { + return { + value, + done, + }; + } + if (isPair) { + forwardTracks(raw, value[0]); + forwardTracks(raw, value[1]); + return { + value: [toReactive(value[0]), toReactive(value[1])], + done, + }; + } + forwardTracks(raw, value); + return { + value: toReactive(value), + done, + }; + }, + [Symbol.iterator]() { + return this; + }, + }; + }; +} + +function createInstrumentations() { + const instrumentations = { + get, + has, + add, + set, + delete: deleteItem, + clear, + forEach, + get size() { + return size(this); + }, + }; + const iteratorMethods = ['keys', 'values', 'entries', Symbol.iterator]; + iteratorMethods.forEach(method => { + instrumentations[method as string] = createIterableMethod(method); + }); + + return instrumentations; +} + +const instrumentations = createInstrumentations(); + +export function createCollectionProxy(target: any) { + return new Proxy(target, { + get( + target: CollectionTypes, + key: string, + receiver: CollectionTypes, + ) { + if (key === ReactiveFlags.PROPERTY) { + return getPropertyNames(target); + } + if (key === ReactiveFlags.RAW) { + return target; + } + + if (hasOwnProperty(instrumentations, key) && key in target) { + return Reflect.get(instrumentations, key, receiver); + } + return Reflect.get(target, key, receiver); + }, + }); +} \ No newline at end of file diff --git a/packages/pwc/src/reactivity/createReactive.ts b/packages/pwc/src/reactivity/createReactive.ts new file mode 100644 index 0000000..354f1c8 --- /dev/null +++ b/packages/pwc/src/reactivity/createReactive.ts @@ -0,0 +1,44 @@ +import { isObject, toRaw, toRawType } from '../utils'; +import { createBaseProxy } from './baseProxy'; +import { createCollectionProxy } from './collectionProxy'; +import { keepTrack } from './track'; + +// store the collection of original object and reactive object +const proxyMap = new WeakMap(); + +export function createReactive(target: any, prop: string, handler: any) { + if (!isObject(target)) { + return target; + } + + const raw = toRaw(target); + keepTrack(target, prop, handler); + return toReactive(raw); +} + +export function toReactive(target: any) { + let proxy = proxyMap.get(target); + if (proxy) { + return proxy; + } + + const rawType = toRawType(target); + switch (rawType) { + case 'Object': + case 'Array': + proxy = createBaseProxy(target); + break; + case 'Map': + case 'Set': + case 'WeakMap': + case 'WeakSet': + proxy = createCollectionProxy(target); + break; + default: + return target; + } + // cache proxy + proxyMap.set(target, proxy); + + return proxy; +} diff --git a/packages/pwc/src/reactivity/handler.ts b/packages/pwc/src/reactivity/handler.ts deleted file mode 100644 index bfcdf93..0000000 --- a/packages/pwc/src/reactivity/handler.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { hasOwnProperty, isArray, toRaw } from '../utils'; -import { ReactiveFlags } from '../constants'; - -function get( - target: object, - key: string, - receiver: object, -): any { - if (key === ReactiveFlags.RAW) { - return target; - } - return Reflect.get(target, key, receiver); -} - -function createSetter(trigger) { - return function set( - target: object, - key: string, - value: unknown, - receiver: object, - ) { - const result = Reflect.set(target, key, value, receiver); - const originTarget = toRaw(receiver); - - // Ignore the set which happened in a prototype chain - if (originTarget !== target) { - return result; - } - - // Ignore the array.length changes - if (!isArray(target) || key !== 'length') { - trigger(); - } - - return result; - }; -} - -function createDeleteProperty(trigger) { - return function deleteProperty( - target: object, - key: string, - ): boolean { - const hadKey = hasOwnProperty(target, key); - const result = Reflect.deleteProperty(target, key); - - if (result && hadKey) { - trigger(); - } - return result; - }; -} -export function getProxyHandler(callback) { - const set = createSetter(callback); - const deleteProperty = createDeleteProperty(callback); - return { - get, - set, - deleteProperty, - }; -} diff --git a/packages/pwc/src/reactivity/reactive.ts b/packages/pwc/src/reactivity/reactive.ts index 60f3d00..20ae036 100644 --- a/packages/pwc/src/reactivity/reactive.ts +++ b/packages/pwc/src/reactivity/reactive.ts @@ -1,5 +1,5 @@ import { isArray, isPlainObject, isPrivate, shallowClone } from '../utils'; -import { getProxyHandler } from './handler'; +import { createReactive } from './createReactive'; interface ReactiveType { initValue: (prop: string, value: unknown) => void; @@ -9,22 +9,24 @@ interface ReactiveType { getValue: (prop: string) => unknown; // If the reactive property changes, it will request a update - requestUpdate: () => void; + requestUpdate: (prop: string) => void; } export class Reactive implements ReactiveType { + changedProperties: Set = new Set(); + static getKey(key: string): string { return `#_${key}`; } #element: any; - #proxyHandler = getProxyHandler(this.requestUpdate.bind(this)); constructor(elementInstance) { this.#element = elementInstance; } - requestUpdate() { + requestUpdate(prop: string) { + this.changedProperties.add(prop); this.#element?._requestUpdate(); } @@ -46,7 +48,7 @@ export class Reactive implements ReactiveType { } if (forceUpdate) { - this.requestUpdate(); + this.requestUpdate(prop); } } @@ -65,6 +67,6 @@ export class Reactive implements ReactiveType { #createReactiveProperty(prop: string, initialValue: any) { const key = Reactive.getKey(prop); - this.#element[key] = new Proxy(initialValue, this.#proxyHandler); + this.#element[key] = createReactive(initialValue, prop, this.requestUpdate.bind(this, prop)); } } diff --git a/packages/pwc/src/reactivity/track.ts b/packages/pwc/src/reactivity/track.ts new file mode 100644 index 0000000..2a171e7 --- /dev/null +++ b/packages/pwc/src/reactivity/track.ts @@ -0,0 +1,61 @@ + +type Handler = () => void; + +// The propMap stores the connection of target and propNames. +// PropName is a custom element's property. +const propMap = new WeakMap>(); + +// The handlerMap stores the connection of target and handlers. +// Handler is the callback that should triggered when the target changed. +const handlerMap = new WeakMap>(); + +export function getPropertyNames(target: any) { + return propMap.get(target); +} + +export function setPropertyNames(target: any, prop: string) { + const props = getPropertyNames(target); + if (props) { + props.add(prop); + } else { + propMap.set(target, new Set([prop])); + } +} + +export function getHandlers(target: any) { + return handlerMap.get(target); +} + +export function setHandlers(target: any, handler: () => void) { + const handlers = getHandlers(target); + if (handlers) { + handlers.add(handler); + } else { + handlerMap.set(target, new Set([handler])); + } +} + +export function forwardTracks(source: any, target: any) { + const props = getPropertyNames(source); + const handlers = getHandlers(source); + + if (props) { + propMap.set(target, props); + } + if (handlers) { + handlerMap.set(target, handlers); + } +} + +export function keepTrack(target, prop, handler) { + setPropertyNames(target, prop); + setHandlers(target, handler); +} + +export function runHandlers(target: any) { + const handlers = getHandlers(target); + + if (handlers) { + handlers.forEach(handler => handler()); + } +} \ No newline at end of file diff --git a/packages/pwc/src/utils/checkTypes.ts b/packages/pwc/src/utils/checkTypes.ts index 3053203..ae690f3 100644 --- a/packages/pwc/src/utils/checkTypes.ts +++ b/packages/pwc/src/utils/checkTypes.ts @@ -19,6 +19,10 @@ export function isFunction(value: unknown) { return typeof value === 'function'; } +export function isObject(value: unknown) { + return typeof value === 'object'; +} + export function isPlainObject(value: unknown) { return Object.prototype.toString.call(value) === '[object Object]'; } @@ -47,3 +51,6 @@ export function isTemplate(value: unknown): boolean { export function isFalsy(value: unknown) { return !value && value !== 0; } +export function toRawType(value: unknown): string { + return Object.prototype.toString.call(value).slice(8, -1); +} diff --git a/packages/pwc/src/utils/index.ts b/packages/pwc/src/utils/index.ts index 15caca6..ec542ef 100644 --- a/packages/pwc/src/utils/index.ts +++ b/packages/pwc/src/utils/index.ts @@ -4,5 +4,5 @@ export * from './shallowEqual'; export * from './generateUid'; export * from './checkTypes'; export * from './shallowClone'; -export * from './toRaw'; +export * from './reactiveMethods'; diff --git a/packages/pwc/src/utils/reactiveMethods.ts b/packages/pwc/src/utils/reactiveMethods.ts new file mode 100644 index 0000000..c5fd78d --- /dev/null +++ b/packages/pwc/src/utils/reactiveMethods.ts @@ -0,0 +1,15 @@ +import { ReactiveFlags } from '../constants'; + +export function toRaw(observed: T): T { + const raw = observed && observed[ReactiveFlags.RAW]; + return raw ? toRaw(raw) : observed; +} + +// get the propNames which the reactive obj created from +export function getProperties(observed): Set | undefined { + return observed && observed[ReactiveFlags.PROPERTY] ? observed[ReactiveFlags.PROPERTY] : undefined; +} + +export function isReactive(observed): boolean { + return observed && observed[ReactiveFlags.IS_REACTIVE]; +} diff --git a/packages/pwc/src/utils/shallowEqual.ts b/packages/pwc/src/utils/shallowEqual.ts index d1fc83a..f38c4d9 100644 --- a/packages/pwc/src/utils/shallowEqual.ts +++ b/packages/pwc/src/utils/shallowEqual.ts @@ -21,7 +21,16 @@ export function shallowEqual(valueA: any, valueB: any) { if (!hasOwnProperty(valueB, val) || !isEvent(valueA[val]) || !is(valueA[val], valueB[val])) { return false; } + for (let index = 0; index < valueA.length; index++) { + const itemA = valueA[index]; + const itemB = valueB[index]; + if (isEvent(itemA.name)) { + continue; + } + if (!is(itemA.value, itemB.value)) { + return false; + } + } } - return true; } diff --git a/packages/pwc/src/utils/toRaw.ts b/packages/pwc/src/utils/toRaw.ts deleted file mode 100644 index e55ac3f..0000000 --- a/packages/pwc/src/utils/toRaw.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ReactiveFlags } from '../constants'; - -export function toRaw(observed: T): T { - const raw = observed && observed[ReactiveFlags.RAW]; - return raw ? toRaw(raw) : observed; -} \ No newline at end of file From 1a7b9b706a630f5779b01bd2034ac8595058d2fc Mon Sep 17 00:00:00 2001 From: "chenrongyan.cry" Date: Fri, 13 May 2022 17:39:08 +0800 Subject: [PATCH 2/6] feat: add collection proxy && support nesting templates diff --- examples/condition/index.html | 21 ++ examples/condition/main.js | 27 +++ examples/condition/package.json | 18 ++ examples/condition/vite.config.js | 33 +++ examples/list/main.js | 87 ++++++-- packages/pwc/build.config.ts | 2 +- .../elements/__tests__/HTMLElement.test.ts | 4 +- .../{ => part}/commitAttributes.test.ts | 129 ++++++++++-- .../__tests__/part/commitTemplates.test.ts | 12 ++ packages/pwc/src/elements/commitAttributes.ts | 46 ---- packages/pwc/src/elements/part/attributes.ts | 61 ++++++ packages/pwc/src/elements/part/base.ts | 23 ++ packages/pwc/src/elements/part/index.ts | 5 + packages/pwc/src/elements/part/template.ts | 87 ++++++++ packages/pwc/src/elements/part/text.ts | 32 +++ .../elements/part/utils/commitAttributes.ts | 184 ++++++++++++++++ .../elements/part/utils/commitTemplates.ts | 119 +++++++++++ .../{ => part/utils}/createTemplate.ts | 2 +- .../utils}/elementTemplateManager.ts | 6 +- .../{ => part/utils}/formatElementTemplate.ts | 8 +- packages/pwc/src/elements/part/utils/index.ts | 6 + .../part/utils/toTextCommentDataType.ts | 18 ++ .../src/elements/reactiveElementFactory.ts | 32 ++- packages/pwc/src/elements/reactiveNode.ts | 197 ------------------ .../pwc/src/elements/renderElementTemplate.ts | 52 ----- packages/pwc/src/type.ts | 20 +- packages/pwc/src/utils/shallowEqual.ts | 2 +- pnpm-lock.yaml | 13 ++ 28 files changed, 877 insertions(+), 369 deletions(-) create mode 100644 examples/condition/index.html create mode 100644 examples/condition/main.js create mode 100644 examples/condition/package.json create mode 100644 examples/condition/vite.config.js rename packages/pwc/src/elements/__tests__/{ => part}/commitAttributes.test.ts (55%) create mode 100644 packages/pwc/src/elements/__tests__/part/commitTemplates.test.ts delete mode 100644 packages/pwc/src/elements/commitAttributes.ts create mode 100644 packages/pwc/src/elements/part/attributes.ts create mode 100644 packages/pwc/src/elements/part/base.ts create mode 100644 packages/pwc/src/elements/part/index.ts create mode 100644 packages/pwc/src/elements/part/template.ts create mode 100644 packages/pwc/src/elements/part/text.ts create mode 100644 packages/pwc/src/elements/part/utils/commitAttributes.ts create mode 100644 packages/pwc/src/elements/part/utils/commitTemplates.ts rename packages/pwc/src/elements/{ => part/utils}/createTemplate.ts (77%) rename packages/pwc/src/elements/{ => part/utils}/elementTemplateManager.ts (75%) rename packages/pwc/src/elements/{ => part/utils}/formatElementTemplate.ts (71%) create mode 100644 packages/pwc/src/elements/part/utils/index.ts create mode 100644 packages/pwc/src/elements/part/utils/toTextCommentDataType.ts delete mode 100644 packages/pwc/src/elements/reactiveNode.ts delete mode 100644 packages/pwc/src/elements/renderElementTemplate.ts diff --git a/examples/condition/index.html b/examples/condition/index.html new file mode 100644 index 0000000..fae5672 --- /dev/null +++ b/examples/condition/index.html @@ -0,0 +1,21 @@ + + + + + + Basic + + + + + + + + diff --git a/examples/condition/main.js b/examples/condition/main.js new file mode 100644 index 0000000..1133fe2 --- /dev/null +++ b/examples/condition/main.js @@ -0,0 +1,27 @@ +import { reactive, customElement, html } from 'pwc'; + +@customElement('custom-element') +class CustomElement extends HTMLElement { + @reactive + accessor #condition = true; + + @reactive + accessor #icon = ''; + + + handleClick() { + console.log('click'); + this.#condition = !this.#condition; + this.#icon += '!'; + } + + get template() { + return html`
+

Condition is ${this.#condition}

+ ${this.#condition ? html`

True Condition${this.#icon}

` : html`

False Condition${this.#icon}

`} +
`; + } +} + + +//

vs

\ No newline at end of file diff --git a/examples/condition/package.json b/examples/condition/package.json new file mode 100644 index 0000000..19ad9cb --- /dev/null +++ b/examples/condition/package.json @@ -0,0 +1,18 @@ +{ + "name": "basic", + "version": "0.0.0", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "rollup": "^2.0.0", + "vite": "^2.7.2", + "vite-plugin-babel": "^1.0.0" + }, + "dependencies": { + "pwc": "workspace:*" + }, + "browserslist": "chrome > 60" +} diff --git a/examples/condition/vite.config.js b/examples/condition/vite.config.js new file mode 100644 index 0000000..a8a7406 --- /dev/null +++ b/examples/condition/vite.config.js @@ -0,0 +1,33 @@ +import { defineConfig } from 'vite'; +import babel from 'vite-plugin-babel'; + +export default defineConfig({ + plugins: [ + babel({ + babelConfig: { + presets: [ + [ + '@babel/preset-env', + { + targets: { + chrome: 99, + }, + modules: false, + }, + ], + ], + plugins: [ + [ + '@babel/plugin-proposal-decorators', + { + version: '2021-12', + }, + ], + '@babel/plugin-proposal-class-properties', + '@babel/plugin-proposal-class-static-block', + '@babel/plugin-proposal-private-methods' + ], + }, + }), + ], +}); diff --git a/examples/list/main.js b/examples/list/main.js index 207fa2d..ec8d519 100644 --- a/examples/list/main.js +++ b/examples/list/main.js @@ -8,27 +8,88 @@ class CustomElement extends HTMLElement { @reactive accessor #list = ['item 1', 'item 2', 'item 3', 'item 4']; + @reactive + accessor #flag = true; + onClick() { this.#list.push('item 5'); } handleItemClick(index) { + console.log(index); this.#list = [...this.#list.slice(0, index), ...this.#list.slice(index + 1)]; } + handleToggle() { + this.#flag = !this.#flag; + } + get template() { - return html`
- ${html`${this.#list.map((item, index) => { - if (item === 'item 2') { - return null; - } - if (item === 'item 3') { - return [1, 2, 3].map((insideItem) => { - return html`
this.handleItemClick(index)}>inside list: ${insideItem}
`; - }); - } - return html`
this.handleItemClick(index)}>${item}
`; - })}`} -
`; + return html`
${this.#flag + ''}
`; + // let result; + // if (this.#flag) { + // result = this.#list.map((item, index) => { + // return html`
${item}
` + // }); + // } else { + // result = html`
${this.#flag + ''}
`; + // } + // return result; + + // const result = html`
+ // ${html`${this.#list.map((item, index) => { + // if (item === 'item 2') { + // return null; + // } + // if (item === 'item 3') { + // return [1, 2, 3].map((insideItem) => { + // return html`
this.handleItemClick(index)}>inside list: ${insideItem}
`; + // }); + // } + // return html`
this.handleItemClick(index)}>${item}
`; + // })}`} + //
`; + // console.log(result); + // return result; + + // let result; + // if (this.#flag) { + // result = html`
+ // ${this.#flag + ''} + //
`; + // } else { + // result = html`
+ // ${html`${this.#list.map((item, index) => { + // if (item === 'item 2') { + // return null; + // } + // if (item === 'item 3') { + // return [1, 2, 3].map((insideItem) => { + // return html`
this.handleItemClick(index)}>inside list: ${insideItem}
`; + // }); + // } + // return html`
this.handleItemClick(index)}>${item}
`; + // })}`} + //
`; + // } + // console.log(result); + // return result; + + // const result = html`${this.#list.map((item, index) => { + // if (item === 'item 2') { + // return null; + // } + // if (item === 'item 3') { + // return [1, 2, 3].map((insideItem) => { + // return html`
this.handleItemClick(index)}> + // inside list: ${insideItem} + //
`; + // }); + // } + // return html`
this.handleItemClick(index)}>${item}
`; + // })}`; + + // console.log(result); + // return result; } } diff --git a/packages/pwc/build.config.ts b/packages/pwc/build.config.ts index e287d5f..ca526ba 100644 --- a/packages/pwc/build.config.ts +++ b/packages/pwc/build.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from '@ice/pkg'; export default defineConfig({ - sourceMaps: 'inline', + sourceMaps: false, transform: { excludes: ['**/__tests__/**'], diff --git a/packages/pwc/src/elements/__tests__/HTMLElement.test.ts b/packages/pwc/src/elements/__tests__/HTMLElement.test.ts index 82bcd93..71036f7 100644 --- a/packages/pwc/src/elements/__tests__/HTMLElement.test.ts +++ b/packages/pwc/src/elements/__tests__/HTMLElement.test.ts @@ -443,7 +443,7 @@ describe('render multiple kinds template', () => { document.body.appendChild(el2); expect(el2.innerHTML).toEqual( - '132', + '132', ); @customElement('hybrid-list') @@ -493,7 +493,7 @@ describe('render multiple kinds template', () => { inside list: 2
inside list: 3 -
item4
`); +
item4
`); const item3 = document.getElementsByClassName('item3')[0]; item3.click(); await nextTick(); diff --git a/packages/pwc/src/elements/__tests__/commitAttributes.test.ts b/packages/pwc/src/elements/__tests__/part/commitAttributes.test.ts similarity index 55% rename from packages/pwc/src/elements/__tests__/commitAttributes.test.ts rename to packages/pwc/src/elements/__tests__/part/commitAttributes.test.ts index 2b0e5d9..53cf348 100644 --- a/packages/pwc/src/elements/__tests__/commitAttributes.test.ts +++ b/packages/pwc/src/elements/__tests__/part/commitAttributes.test.ts @@ -1,9 +1,9 @@ import { jest } from '@jest/globals' -import { commitAttributes } from '../commitAttributes'; -import '../native/HTMLElement'; +import { commitAttributes } from '../../part'; +import '../../native/HTMLElement'; -describe('Set element attribute/property/event handler', () => { - it('should set attribute at built-in element', () => { +describe('Set/Update/Remove element attribute/property/event handler', () => { + it('should set/update/remove attribute at built-in element', () => { const attrs = [ { name: 'data-index', @@ -20,6 +20,30 @@ describe('Set element attribute/property/event handler', () => { expect(div.dataset.index).toEqual('1'); expect(div.getAttribute('class')).toEqual('container'); expect(div.classList.contains('container')).toBeTruthy(); + + const currentAttrs = [ + // update + { + name: 'data-index', + value: 2 + }, + // add + { + name: 'id', + value: 'demo' + } + // remove class + ]; + commitAttributes(div, [attrs, currentAttrs]); + + expect(div.getAttribute('data-index')).toEqual('2'); + expect(div.dataset.index).toEqual('2'); + + expect(div.getAttribute('class')).toBe(null); + expect(div.classList.length).toBe(0); + + expect(div.getAttribute('id')).toEqual('demo'); + expect(div.id).toEqual('demo'); }); it('should set attribute and event handler at built-in element', () => { @@ -90,9 +114,10 @@ describe('Set element attribute/property/event handler', () => { expect(childClickHandler).toBeCalledTimes(2); }); - it('should set attribute and property at custom element', () => { + it('should set/update/remove attribute and property at custom element', () => { class CustomElement extends HTMLElement { description = 'default description'; + number = 0; } window.customElements.define('custom-element', CustomElement); @@ -108,6 +133,10 @@ describe('Set element attribute/property/event handler', () => { name: 'class', value: 'container', }, + { + name: 'title', + value: 'This is a title' + }, { name: 'description', value: 'This is custom element', @@ -121,25 +150,86 @@ describe('Set element attribute/property/event handler', () => { expect(customElement.getAttribute('class')).toEqual('container'); expect(customElement.classList.contains('container')).toBeTruthy(); // @ts-ignore + expect(customElement.title).toEqual('This is a title'); + // @ts-ignore expect(customElement.description).toEqual('This is custom element'); + + const currentAttrs = [ + // update attribute + { + name: 'data-index', + value: 2 + }, + // add attribute + { + name: 'id', + value: 'demo' + }, + // remove attribute + // update property + { + name: 'title', + value: 'Title Changed' + }, + // add property + { + name: 'number', + value: 1 + } + // remove property + ]; + + commitAttributes(customElement, [attrs, currentAttrs]); + + expect(customElement.getAttribute('data-index')).toEqual('2'); + expect(customElement.dataset.index).toEqual('2'); + + expect(customElement.getAttribute('class')).toBe(null); + expect(customElement.classList.length).toBe(0); + + expect(customElement.getAttribute('id')).toEqual('demo'); + expect(customElement.id).toEqual('demo'); + + // @ts-ignore + expect(customElement.title).toEqual('Title Changed'); + // @ts-ignore + expect(customElement.number).toBe(1); + // @ts-ignore + expect(customElement.description).toBe(undefined); }); - it('should only add event listener once with component update', () => { - const mockClickHandler = jest.fn(); + it('should add/update/remove event listener at element', () => { + const mockClickHandler1 = jest.fn(); const div = document.createElement('div'); const attrs = [ { name: 'onclick', - handler: mockClickHandler, + handler: mockClickHandler1, capture: true, } ]; - commitAttributes(div, attrs, { isInitial: true }); - div.click(); - expect(mockClickHandler).toBeCalledTimes(1); commitAttributes(div, attrs); div.click(); - expect(mockClickHandler).toBeCalledTimes(2); + expect(mockClickHandler1).toBeCalledTimes(1); + + const mockClickHandler2 = jest.fn(); + const changedAttrs = [ + { + name: 'onclick', + handler: mockClickHandler2, + capture: true, + } + ]; + commitAttributes(div, [attrs, changedAttrs]); + div.click(); + expect(mockClickHandler1).toBeCalledTimes(1); + expect(mockClickHandler2).toBeCalledTimes(1); + + const removeAttrs = []; + commitAttributes(div, [changedAttrs, removeAttrs]); + div.click(); + expect(mockClickHandler1).toBeCalledTimes(1); + expect(mockClickHandler2).toBeCalledTimes(1); }); it('Svg elements should be set as attributes', () => { @@ -147,10 +237,21 @@ describe('Set element attribute/property/event handler', () => { const attrs = [{ name: 'width', value: '200' + }, { + name: 'height', + value: '200' }]; - commitAttributes(svg, attrs, { isInitial: true }); - + commitAttributes(svg, attrs); expect(svg.getAttribute('width')).toEqual('200'); + + const currentAttrs = [{ + name: 'height', + value: '200' + }]; + + commitAttributes(svg, [attrs, currentAttrs]); + expect(svg.getAttribute('width')).toBe(null); + expect(svg.getAttribute('height')).toEqual('200'); }); }); diff --git a/packages/pwc/src/elements/__tests__/part/commitTemplates.test.ts b/packages/pwc/src/elements/__tests__/part/commitTemplates.test.ts new file mode 100644 index 0000000..5d6524b --- /dev/null +++ b/packages/pwc/src/elements/__tests__/part/commitTemplates.test.ts @@ -0,0 +1,12 @@ +import { TEXT_COMMENT_DATA } from "../../../constants"; +import { PWCElement } from "../../../type"; +import { html } from '../../../index'; + +describe('commitTemplates', () => { + const commentNode = document.createComment(TEXT_COMMENT_DATA); + const rootElement = {} as PWCElement; + + it('Simple commit', () => { + + }); +}); \ No newline at end of file diff --git a/packages/pwc/src/elements/commitAttributes.ts b/packages/pwc/src/elements/commitAttributes.ts deleted file mode 100644 index e4f271f..0000000 --- a/packages/pwc/src/elements/commitAttributes.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { isEvent } from '../utils/isEvent'; -import type { Attributes, PWCElement } from '../type'; -import { toRaw } from '../utils'; - -export function commitAttributes(element: Element, attrs: Attributes, opt?: { - isInitial?: boolean; - rootElement?: PWCElement; - isSVG?: boolean; -}) { - const { - isInitial = false, - isSVG = false, - rootElement, - } = opt || {}; - for (const attr of attrs) { - // Bind event - if (isEvent(attr)) { - const { name } = attr; - - // Only add event listener at the first render - if (!isInitial) { - continue; - } - const eventName = name.slice(2).toLowerCase(); - const { capture = false, handler } = attr; - // If capture is true, the event should be triggered when capture stage - // Bind the rootElement to ensure the handler context is the element itself - element.addEventListener(eventName, handler.bind(rootElement), capture); - - continue; - } - - const { name, value } = attr; - - if (isSVG) { - // https://svgwg.org/svg2-draft/struct.html#InterfaceSVGSVGElement - // Svg elements must be set as attributes, all properties is read only - element.setAttribute(name, value); - } else if (name in element) { - // Verify that there is a target property on the element - element[name] = toRaw(value); - } else { - element.setAttribute(name, value); - } - } -} diff --git a/packages/pwc/src/elements/part/attributes.ts b/packages/pwc/src/elements/part/attributes.ts new file mode 100644 index 0000000..4a898d3 --- /dev/null +++ b/packages/pwc/src/elements/part/attributes.ts @@ -0,0 +1,61 @@ +import { Attributes, NormalAttribute, PWCElement } from '../../type'; +import { commitAttributes } from './utils/commitAttributes'; +import { BasePart } from './base'; +import { getProperties } from '../../utils'; + +export class AttributesPart extends BasePart { + #el: Element; + #elIsCustom: boolean; + #elIsSvg: boolean; + + constructor(commentNode: Comment, rootElement: PWCElement, initialValue: Attributes) { + super(commentNode, rootElement, initialValue); + this.#el = this.commentNode.nextSibling as Element; + this.#elIsCustom = Boolean(window.customElements.get(this.#el.localName)); + this.#elIsSvg = this.#el instanceof SVGElement; + this.render(initialValue); + } + + render(value: Attributes) { + if (this.#elIsCustom) { + // @ts-ignore + this.#el.__init_task__ = () => { + this.commitAttributes(value, true); + }; + } else { + this.commitAttributes(value, true); + } + } + + commitValue([prev, current]: [Attributes, Attributes]) { + this.commitAttributes([prev, current]); + + // Any updating should trigger the child components's update method + if (this.#elIsCustom && (this.#el as PWCElement)._requestUpdate) { + (this.#el as PWCElement)._requestUpdate(); + } + } + + commitAttributes(value: Attributes | [Attributes, Attributes], isInitial = false) { + const changedProperties = this.rootElement._getChangedProperties(); + + function isAttributeChanged(attr: NormalAttribute): boolean { + const { value } = attr; + const properties = getProperties(value); + + for (let prop of properties) { + if (changedProperties.has(prop)) { + return true; + } + } + return false; + } + + commitAttributes(this.#el, value, { + isInitial, + isSVG: this.#elIsSvg, + rootElement: this.rootElement, + isAttributeChanged, + }); + } +} \ No newline at end of file diff --git a/packages/pwc/src/elements/part/base.ts b/packages/pwc/src/elements/part/base.ts new file mode 100644 index 0000000..e001744 --- /dev/null +++ b/packages/pwc/src/elements/part/base.ts @@ -0,0 +1,23 @@ +import { PWCElement, TemplateDataItemType } from '../../type'; + +export class BasePart { + commentNode: Comment; + rootElement: PWCElement; + + constructor(commentNode: Comment, rootElement: PWCElement, initialValue: TemplateDataItemType) { + this.commentNode = commentNode; + this.rootElement = rootElement; + } + + // Initial values + init(...args: any[]) {} + + // Remove node + remove() {} + + // Render node + render(value: TemplateDataItemType) {} + + // Trigger update + commitValue([prev, current]: [TemplateDataItemType, TemplateDataItemType]) {} +} diff --git a/packages/pwc/src/elements/part/index.ts b/packages/pwc/src/elements/part/index.ts new file mode 100644 index 0000000..439e11f --- /dev/null +++ b/packages/pwc/src/elements/part/index.ts @@ -0,0 +1,5 @@ +export * from './attributes'; +export * from './base'; +export * from './template'; +export * from './text'; +export * from './utils'; diff --git a/packages/pwc/src/elements/part/template.ts b/packages/pwc/src/elements/part/template.ts new file mode 100644 index 0000000..fa8d0c4 --- /dev/null +++ b/packages/pwc/src/elements/part/template.ts @@ -0,0 +1,87 @@ +import { PLACEHOLDER_COMMENT_DATA, PWC_PREFIX, TEXT_COMMENT_DATA } from '../../constants'; +import { throwError } from '../../error'; +import { Attributes, PWCElement, PWCElementTemplate, RootElement, TemplateDataItemType } from '../../type'; +import { createTemplate, commitTemplates, renderTextCommentTemplate } from './utils'; +import { AttributesPart } from './attributes'; +import { BasePart } from './base'; + +// Scan placeholder node, and commit dynamic data to component +function renderElementTemplate( + fragment: RootElement | Node, + templateData: TemplateDataItemType[], + dynamicTree: DynamicNode[], + rootElement: PWCElement, +) { + const nodeIterator = document.createNodeIterator(fragment, NodeFilter.SHOW_COMMENT, { + acceptNode(node) { + if ((node as Comment).data?.includes(PWC_PREFIX)) { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_REJECT; + }, + }); + let placeholder: Node; + let index = 0; + + while ((placeholder = nodeIterator.nextNode())) { + const value = templateData[index]; + let node: DynamicNode; + + // Insert dynamic text node + if ((placeholder as Comment).data === TEXT_COMMENT_DATA) { + node = renderTextCommentTemplate(placeholder as Comment, rootElement, value); + } else if ((placeholder as Comment).data === PLACEHOLDER_COMMENT_DATA) { + node = { + commentNode: placeholder as Comment, + part: new AttributesPart(placeholder as Comment, rootElement, value as Attributes), + }; + } + dynamicTree.push(node); + index++; + } +} + +export type DynamicNode = { + commentNode: Comment; + part?: BasePart; + children?: DynamicNode[]; +}; +export class TemplatePart extends BasePart { + childNodes: Node[]; + dynamicNode: DynamicNode; + + constructor(commentNode: Comment, rootElement: PWCElement, initialValue: PWCElementTemplate) { + super(commentNode, rootElement, initialValue); + this.childNodes = []; + this.dynamicNode = { + children: [], + part: this, + commentNode: this.commentNode, + }; + this.render(initialValue); + } + + remove() { + if (this.childNodes.length > 0) { + // Clear out-dated nodes + this.childNodes.forEach(childNode => { + childNode.parentNode.removeChild(childNode); + }); + this.childNodes = []; + } + } + + render(elementTemplate: PWCElementTemplate) { + this.remove(); + const { templateString, templateData = [] } = elementTemplate; + const fragment = createTemplate(templateString); + renderElementTemplate(fragment, templateData, this.dynamicNode.children, this.rootElement); + // Cache all native nodes + this.childNodes = [...fragment.childNodes]; + this.commentNode.parentNode.insertBefore(fragment, this.commentNode); + } + + commitValue([prev, current]: [PWCElementTemplate, PWCElementTemplate]) { + commitTemplates([prev, current], this.dynamicNode, this.rootElement); + } +} \ No newline at end of file diff --git a/packages/pwc/src/elements/part/text.ts b/packages/pwc/src/elements/part/text.ts new file mode 100644 index 0000000..19f9890 --- /dev/null +++ b/packages/pwc/src/elements/part/text.ts @@ -0,0 +1,32 @@ +import { PWCElement } from '../../type'; +import { isFalsy } from '../../utils'; +import { BasePart } from './base'; + +export class TextPart extends BasePart { + el: Text; + + constructor(commentNode: Comment, rootElement: PWCElement, initialValue: string) { + super(commentNode, rootElement, initialValue); + this.render(initialValue); + } + + commitValue([prev, current]: [string, string]) { + if (prev !== current) { + this.el.nodeValue = this.formatValue(current); + } + } + + render(value: string) { + const textNode = document.createTextNode(this.formatValue(value)); + this.el = textNode; + this.commentNode.parentNode.insertBefore(textNode, this.commentNode); + } + + remove() { + this.commentNode.parentNode.removeChild(this.el); + } + + formatValue(value: string): string { + return isFalsy(value) ? '' : value; + } +} diff --git a/packages/pwc/src/elements/part/utils/commitAttributes.ts b/packages/pwc/src/elements/part/utils/commitAttributes.ts new file mode 100644 index 0000000..39b8e09 --- /dev/null +++ b/packages/pwc/src/elements/part/utils/commitAttributes.ts @@ -0,0 +1,184 @@ +import { isEvent } from '../../../utils/isEvent'; +import type { Attribute, Attributes, NormalAttribute, PWCElement } from '../../../type'; +import { is, isArray, toRaw } from '../../../utils'; + +type IsAttributeChanged = (attr: NormalAttribute) => boolean; + +type Options = { + isInitial?: boolean; + rootElement?: PWCElement; + isSVG?: boolean; + isAttributeChanged: IsAttributeChanged; +}; + +type Handler = (...args: any[]) => any; + +const handlerMap: WeakMap = new WeakMap(); + +function returnTrue() { + return true; +} + +function getHandler(handler: Handler, rootElement?: PWCElement): Handler { + if (handlerMap.has(handler)) { + return handlerMap.get(handler); + } + const newHandler = handler.bind(rootElement || null); + handlerMap.set(handler, newHandler); + return newHandler; +} + +function isAttributes(attrs: Attributes | [Attributes, Attributes]): boolean { + return attrs.length === 0 || !isArray(attrs[0]); +} + + +enum DiffResult { + 'SAME', + 'CHANGED', + 'RESET', +} + +function diffAttribute(prevAttr: Attribute, currentAttr: Attribute, isAttributeChanged: IsAttributeChanged): DiffResult { + if (isEvent(prevAttr)) { + if (isEvent(currentAttr)) { + const { handler: prevHandler, capture: prevCapture = false } = prevAttr; + const { handler: currentHandler, capture: currentCapture = false } = currentAttr; + if (prevHandler === currentHandler && prevCapture === currentCapture) { + return DiffResult.SAME; + } + } + // If attribute type is different, or event with same event name changed, + // it should be remove the old attribute and add the new one + return DiffResult.RESET; + } + if (!isEvent(currentAttr)) { + if (is(prevAttr, currentAttr) && !isAttributeChanged(currentAttr)) { + return DiffResult.SAME; + } + return DiffResult.CHANGED; + } + + // Attribute type is different + return DiffResult.RESET; +} + +function diffAttributes(prevAttrs: Attributes, currentAttrs: Attributes, isAttributeChanged: IsAttributeChanged): { + changed: Attributes; + removed: Attributes; +} { + const currentMap: Map = new Map(); + currentAttrs.forEach(attr => currentMap.set(attr.name, attr)); + const changed: Attributes = []; + const removed: Attributes = []; + + prevAttrs.forEach(prevAttr => { + const { name } = prevAttr; + if (currentMap.has(name)) { + const currentAttr = currentMap.get(name); + currentMap.delete(name); + + const ret = diffAttribute(prevAttr, currentAttr, isAttributeChanged); + switch (ret) { + case DiffResult.CHANGED: + changed.push(currentAttr); + break; + case DiffResult.RESET: + removed.push(prevAttr); + changed.push(currentAttr); + case DiffResult.SAME: + default: + break; + } + } else { + removed.push(prevAttr); + } + }); + for (let [, attr] of currentMap) { + changed.push(attr); + } + return { + changed, + removed, + }; +} + +function setAttributes(element: Element, attrs: Attributes, opt?: Options) { + const { + // isInitial = false, + isSVG = false, + rootElement, + } = opt || {}; + for (const attr of attrs) { + // Bind event + if (isEvent(attr)) { + const { name } = attr; + + const eventName = name.slice(2).toLowerCase(); + const { capture = false, handler } = attr; + // If capture is true, the event should be triggered when capture stage + // Bind the rootElement to ensure the handler context is the element itself + const newHandler = getHandler(handler, rootElement); + element.addEventListener(eventName, newHandler, capture); + + continue; + } + + const { name, value } = attr; + + if (isSVG) { + // https://svgwg.org/svg2-draft/struct.html#InterfaceSVGSVGElement + // Svg elements must be set as attributes, all properties is read only + element.setAttribute(name, value); + } else if (name in element) { + // Verify that there is a target property on the element + element[name] = toRaw(value); + } else { + element.setAttribute(name, value); + } + } +} + +function removeAttributes(element: Element, attrs: Attributes, opt?: Options) { + const { + isSVG = false, + rootElement, + } = opt || {}; + for (const attr of attrs) { + if (isEvent(attr)) { + const { name, capture = false, handler } = attr; + const eventName = name.slice(2).toLowerCase(); + const newHandler = getHandler(handler, rootElement); + element.removeEventListener(eventName, newHandler, capture); + continue; + } + const { name } = attr; + if (isSVG) { + element.removeAttribute(name); + } else if (name in element) { + delete element[name]; + } else { + element.removeAttribute(name); + } + } +} + +export function commitAttributes(element: Element, attrs: Attributes | [Attributes, Attributes], opt?: Options) { + if (isAttributes(attrs)) { + setAttributes(element, attrs as Attributes, opt); + return; + } + + const { + isAttributeChanged = returnTrue + } = opt || {}; + + const [prevAttrs, currentAttrs] = attrs as [Attributes, Attributes]; + const { + changed, + removed, + } = diffAttributes(prevAttrs, currentAttrs, isAttributeChanged); + + removeAttributes(element, removed, opt); + setAttributes(element, changed, opt); +} diff --git a/packages/pwc/src/elements/part/utils/commitTemplates.ts b/packages/pwc/src/elements/part/utils/commitTemplates.ts new file mode 100644 index 0000000..7b6cb37 --- /dev/null +++ b/packages/pwc/src/elements/part/utils/commitTemplates.ts @@ -0,0 +1,119 @@ +import { Attributes, PWCElement, PWCElementTemplate, TemplateDataItemType } from '../../../type'; +import { AttributesPart, BasePart, DynamicNode, formatElementTemplate, TemplatePart, TextCommentDataType, TextPart, toTextCommentDataType } from '..'; + +function remove(node: DynamicNode) { + if (node.part) { + node.part.remove(); + } else { + for (let item of node.children) { + remove(item); + } + } +} + +export function renderTextCommentTemplate( + commentNode: Comment, + rootElement: PWCElement, + value: TemplateDataItemType, +): DynamicNode { + const node: DynamicNode = { + commentNode, + }; + const dataType = toTextCommentDataType(value); + switch (dataType) { + case TextCommentDataType.Array: { + const children: DynamicNode[] = []; + (value as any[]).forEach(item => { + children.push(renderTextCommentTemplate(commentNode, rootElement, item)); + }); + node.children = children; + break; + } + case TextCommentDataType.Template: { + node.part = new TemplatePart(commentNode, rootElement, value as PWCElementTemplate); + break; + } + default: { + node.part = new TextPart(commentNode, rootElement, value as string); + break; + } + } + return node; +} + +export function commitTemplates( + [prev, current]: [PWCElementTemplate, PWCElementTemplate], + dynamicNode: DynamicNode, + rootElement: PWCElement, +) { + const { + templateString: prevTemplateString, + templateData: prevTemplateData, + } = prev; + const { + templateString, + templateData, + } = current; + // If template strings is constant with prev ones, + // it should just update node values and attributes + if (prevTemplateString === templateString && dynamicNode.children) { + for (let index = 0; index < templateData.length; index++) { + const node = dynamicNode.children[index]; + const prevData = prevTemplateData[index]; + const data = templateData[index]; + + // Create new part + if (!node) { + dynamicNode.children[index] = renderTextCommentTemplate(dynamicNode.commentNode, rootElement, data); + continue; + } + + // AttributesPart updated + if (node.part instanceof AttributesPart) { + node.part.commitValue([prevData as Attributes, data as Attributes]); + continue; + } + + const prevDataType = toTextCommentDataType(prevData); + const dataType = toTextCommentDataType(data); + + // If data type is different, it should re render parts + if (prevDataType !== dataType) { + remove(node); + dynamicNode.children[index] = renderTextCommentTemplate(node.commentNode, rootElement, data); + continue; + } + + if (node.children) { + let cIndex = 0; + for (; cIndex < (data as any[]).length; cIndex++) { + if (node.children[cIndex]) { + commitTemplates( + [ + formatElementTemplate(prevData[cIndex]), + formatElementTemplate(data[cIndex]), + ], node.children[cIndex], rootElement); + } else { + node.children.push(renderTextCommentTemplate(node.commentNode, rootElement, data[cIndex])); + } + } + for (; cIndex < node.children.length; cIndex++) { + remove(node.children[cIndex]); + } + continue; + } + + if (node.part instanceof BasePart) { + node.part.commitValue([prevData, data]); + } + } + return; + } + // If template strings changed, it should rerender + remove(dynamicNode); + const { part, children } = renderTextCommentTemplate(dynamicNode.commentNode, rootElement, current); + + // Passing valus + dynamicNode.part = part; + dynamicNode.children = children; +} diff --git a/packages/pwc/src/elements/createTemplate.ts b/packages/pwc/src/elements/part/utils/createTemplate.ts similarity index 77% rename from packages/pwc/src/elements/createTemplate.ts rename to packages/pwc/src/elements/part/utils/createTemplate.ts index 37d7ec0..1116c0b 100644 --- a/packages/pwc/src/elements/createTemplate.ts +++ b/packages/pwc/src/elements/part/utils/createTemplate.ts @@ -1,4 +1,4 @@ -import type { TemplateStringType } from '../type'; +import type { TemplateStringType } from '../../../type'; export function createTemplate(tplStr: TemplateStringType): Node { const template = document.createElement('template'); diff --git a/packages/pwc/src/elements/elementTemplateManager.ts b/packages/pwc/src/elements/part/utils/elementTemplateManager.ts similarity index 75% rename from packages/pwc/src/elements/elementTemplateManager.ts rename to packages/pwc/src/elements/part/utils/elementTemplateManager.ts index fd9e29c..59b571e 100644 --- a/packages/pwc/src/elements/elementTemplateManager.ts +++ b/packages/pwc/src/elements/part/utils/elementTemplateManager.ts @@ -1,5 +1,5 @@ -import type { Fn } from '../type'; -import { isFalsy, isTemplate } from '../utils'; +import type { Fn } from '../../../type'; +import { isArray, isFalsy, isTemplate } from '../../../utils'; interface ManagerActions { falsyAction: Fn; @@ -18,7 +18,7 @@ export function elementTemplateManager(elementTemplate, { falsyAction(); } else if (isTemplate(elementTemplate)) { pwcElementTemplateAction(); - } else if (arrayAction) { + } else if (isArray(elementTemplate)) { arrayAction(); } else { textAction(); diff --git a/packages/pwc/src/elements/formatElementTemplate.ts b/packages/pwc/src/elements/part/utils/formatElementTemplate.ts similarity index 71% rename from packages/pwc/src/elements/formatElementTemplate.ts rename to packages/pwc/src/elements/part/utils/formatElementTemplate.ts index c0288c2..63a0bb4 100644 --- a/packages/pwc/src/elements/formatElementTemplate.ts +++ b/packages/pwc/src/elements/part/utils/formatElementTemplate.ts @@ -1,5 +1,5 @@ -import { TemplateData, TemplateString } from '../constants'; -import { ElementTemplate, PWCElementTemplate } from '../type'; +import { TemplateData, TemplateString, TEXT_COMMENT_DATA } from '../../../constants'; +import { ElementTemplate, PWCElementTemplate } from '../../../type'; import { elementTemplateManager } from './elementTemplateManager'; export function formatElementTemplate(elementTemplate: ElementTemplate): PWCElementTemplate { @@ -18,6 +18,10 @@ export function formatElementTemplate(elementTemplate: ElementTemplate): PWCElem textAction() { templateString = elementTemplate; }, + arrayAction() { + templateString = ``; + templateData = [elementTemplate]; + }, }); // TODO: xss diff --git a/packages/pwc/src/elements/part/utils/index.ts b/packages/pwc/src/elements/part/utils/index.ts new file mode 100644 index 0000000..5403bc2 --- /dev/null +++ b/packages/pwc/src/elements/part/utils/index.ts @@ -0,0 +1,6 @@ +export * from './commitAttributes'; +export * from './createTemplate'; +export * from './elementTemplateManager'; +export * from './formatElementTemplate'; +export * from './toTextCommentDataType'; +export * from './commitTemplates'; diff --git a/packages/pwc/src/elements/part/utils/toTextCommentDataType.ts b/packages/pwc/src/elements/part/utils/toTextCommentDataType.ts new file mode 100644 index 0000000..985a175 --- /dev/null +++ b/packages/pwc/src/elements/part/utils/toTextCommentDataType.ts @@ -0,0 +1,18 @@ +import { TemplateDataItemType } from '../../../type'; +import { isArray, isTemplate } from '../../../utils'; + +export enum TextCommentDataType { + 'Array', + 'Template', + 'Text', +} + +export function toTextCommentDataType(value: TemplateDataItemType) { + if (isArray(value)) { + return TextCommentDataType.Array; + } + if (isTemplate(value)) { + return TextCommentDataType.Template; + } + return TextCommentDataType.Text; +} \ No newline at end of file diff --git a/packages/pwc/src/elements/reactiveElementFactory.ts b/packages/pwc/src/elements/reactiveElementFactory.ts index 79a253b..0834320 100644 --- a/packages/pwc/src/elements/reactiveElementFactory.ts +++ b/packages/pwc/src/elements/reactiveElementFactory.ts @@ -1,9 +1,8 @@ -import type { ElementTemplate, PWCElement, ReflectProperties, RootElement, ReactiveNode, PWCElementTemplate } from '../type'; +import type { ElementTemplate, PWCElement, ReflectProperties, RootElement, PWCElementTemplate } from '../type'; import { Reactive } from '../reactivity/reactive'; -import { TemplateNode, TemplatesNode } from './reactiveNode'; -import { generateUid, isArray } from '../utils'; +import { TemplatePart, formatElementTemplate } from './part'; +import { generateUid } from '../utils'; import { enqueueJob } from './sheduler'; -import { formatElementTemplate } from './formatElementTemplate'; import { TEXT_COMMENT_DATA } from '../constants'; export default (Definition: PWCElement) => { @@ -14,9 +13,9 @@ export default (Definition: PWCElement) => { // The root element #root: RootElement; // Template info - #currentTemplate: ElementTemplate | ElementTemplate[]; - // Reactive nodes - #reactiveNode: ReactiveNode; + #currentTemplate: PWCElementTemplate; + // + #dynamicPart: TemplatePart; // Reactive instance #reactive: Reactive = new Reactive(this); // Reflect properties @@ -34,18 +33,12 @@ export default (Definition: PWCElement) => { // @ts-ignore this.__init_task__(); } - let currentTemplate = this.template; + this.#currentTemplate = formatElementTemplate(this.template); this.#root = this.shadowRoot || this; // This pwc element root base comment node const commentNode = document.createComment(TEXT_COMMENT_DATA); this.appendChild(commentNode); - if (isArray(currentTemplate)) { - this.#reactiveNode = new TemplatesNode(commentNode, this, currentTemplate as PWCElementTemplate[]); - } else { - currentTemplate = formatElementTemplate(currentTemplate); - this.#reactiveNode = new TemplateNode(commentNode, this, currentTemplate as PWCElementTemplate); - } - this.#currentTemplate = currentTemplate; + this.#dynamicPart = new TemplatePart(commentNode, this, this.#currentTemplate as PWCElementTemplate); this.#initialized = true; } } @@ -59,10 +52,9 @@ export default (Definition: PWCElement) => { } #performUpdate() { - const nextElementTemplate = this.template; - const newPWCElementTemplate = formatElementTemplate(nextElementTemplate); + const newPWCElementTemplate = formatElementTemplate(this.template); // The root reactive node must be TemplateNode - this.#reactiveNode.commitValue([this.#currentTemplate, newPWCElementTemplate]); + this.#dynamicPart.commitValue([this.#currentTemplate, newPWCElementTemplate]); this.#currentTemplate = newPWCElementTemplate; } @@ -91,5 +83,9 @@ export default (Definition: PWCElement) => { _getReflectProperties() { return this.#reflectProperties; } + + _getChangedProperties(): Set { + return this.#reactive.changedProperties; + } }; }; diff --git a/packages/pwc/src/elements/reactiveNode.ts b/packages/pwc/src/elements/reactiveNode.ts deleted file mode 100644 index a11475e..0000000 --- a/packages/pwc/src/elements/reactiveNode.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { Attributes, PWCElementTemplate, PWCElement, ReactiveNode, ReactiveNodeMapType, ReactiveNodeValue, ElementTemplate } from '../type'; -import { commitAttributes } from './commitAttributes'; -import { isFalsy, shallowEqual } from '../utils'; -import { renderElementTemplate } from './renderElementTemplate'; -import { NodeType } from '../constants'; -import { formatElementTemplate } from './formatElementTemplate'; -import { createTemplate } from './createTemplate'; -import { elementTemplateManager } from './elementTemplateManager'; - -class BaseNode { - commentNode: Comment; - rootElement: PWCElement; - value: ReactiveNodeValue; - reactiveNodes: ReactiveNode[] = []; - constructor(commentNode: Comment, rootElement: PWCElement, initialValue: ReactiveNodeValue) { - this.commentNode = commentNode; - this.rootElement = rootElement; - this.value = initialValue; - } -} - -export class TextNode extends BaseNode implements ReactiveNode { - #el: Text; - - constructor(commentNode: Comment, rootElement: PWCElement, initialValue: string) { - super(commentNode, rootElement, initialValue); - this.render(); - } - - commitValue(value: string) { - this.value = this.formatValue(value); - this.#el.nodeValue = this.value; - } - - render() { - this.value = this.formatValue(this.value as string); - const textNode = document.createTextNode(this.value); - this.#el = textNode; - this.commentNode.parentNode.insertBefore(textNode, this.commentNode); - } - - formatValue(value: string): string { - return isFalsy(value) ? '' : value; - } -} - -export class AttributedNode extends BaseNode implements ReactiveNode { - #el: Element; - #elIsCustom: boolean; - #elIsSvg: boolean; - - constructor(commentNode: Comment, rootElement: PWCElement, initialAttrs: Attributes) { - super(commentNode, rootElement, initialAttrs); - this.#el = commentNode.nextSibling as Element; - this.#elIsCustom = Boolean(window.customElements.get(this.#el.localName)); - this.#elIsSvg = this.#el instanceof SVGElement; - this.render(); - } - - render() { - if (this.#elIsCustom) { - // @ts-ignore - this.#el.__init_task__ = () => { - this.#commitAttributes(this.value as Attributes, true); - }; - } else { - this.#commitAttributes(this.value as Attributes, true); - } - } - - commitValue(value: Attributes) { - this.#commitAttributes(value); - - // Any updating should trigger the child components's update method - if (this.#elIsCustom && (this.#el as PWCElement)._requestUpdate) { - (this.#el as PWCElement)._requestUpdate(); - } - } - - #commitAttributes(value: Attributes, isInitial = false) { - commitAttributes(this.#el, value, { - isInitial, - isSVG: this.#elIsSvg, - rootElement: this.rootElement, - }); - } -} - -export class TemplateNode extends BaseNode implements ReactiveNode { - childNodes: Node[]; - constructor(commentNode: Comment, rootElement: PWCElement, elementTemplate: PWCElementTemplate) { - super(commentNode, rootElement, elementTemplate); - this.render(); - } - render() { - const { templateString, templateData = [] } = formatElementTemplate(this.value as ElementTemplate); - const fragment = createTemplate(templateString); - // Cache all native nodes - this.childNodes = [...fragment.childNodes]; - renderElementTemplate(fragment, templateData, this.reactiveNodes, this.rootElement, ReactiveNodeMap); - this.commentNode.parentNode.insertBefore(fragment, this.commentNode); - } - commitValue([prev, current]: [PWCElementTemplate, PWCElementTemplate]) { - updateView(prev, current, this.reactiveNodes); - } -} - -export class TemplatesNode extends BaseNode implements ReactiveNode { - childNodes: Node[]; - constructor(commentNode: Comment, rootElement: PWCElement, elementTemplates: PWCElementTemplate[]) { - super(commentNode, rootElement, elementTemplates); - this.render([, elementTemplates]); - } - - commitValue([prev, current]: [ElementTemplate[], ElementTemplate[]]) { - // Delete reactive children nodes - this.deleteChildren(this); - // Rebuild - this.render([prev, current]); - } - render([, current]: [ElementTemplate[], ElementTemplate[]]) { - for (let elementTemplate of current) { - let ReactiveNodeCtor; - elementTemplateManager(elementTemplate, { - falsyAction() { - ReactiveNodeCtor = TextNode; - elementTemplate = ''; - }, - pwcElementTemplateAction() { - ReactiveNodeCtor = TemplateNode; - }, - textAction() { - ReactiveNodeCtor = TextNode; - }, - arrayAction() { - ReactiveNodeCtor = TemplatesNode; - }, - }); - - this.reactiveNodes.push(new ReactiveNodeCtor(this.commentNode, this.rootElement, elementTemplate)); - } - } - - deleteChildren(targetReactiveNode: ReactiveNode) { - for (const reactiveNode of targetReactiveNode.reactiveNodes) { - (reactiveNode as TemplateNode).childNodes?.forEach(childNode => { - const parent = childNode.parentNode; - parent.removeChild(childNode); - }); - if (reactiveNode.reactiveNodes.length > 0) { - this.deleteChildren(reactiveNode); - } - } - targetReactiveNode.reactiveNodes = []; - } -} - -export function updateView( - oldElementTemplate: PWCElementTemplate, - newElementTemplate: PWCElementTemplate, - reactiveNodes: ReactiveNode[], -) { - const { - templateString: oldTemplateString, - templateData: oldTemplateData, - } = oldElementTemplate; - const { - templateString, - templateData, - } = newElementTemplate; - // While template strings is constant with prev ones, - // it should just update node values and attributes - if (oldTemplateString === templateString) { - for (let index = 0; index < oldTemplateData.length; index++) { - const reactiveNode = reactiveNodes[index]; - // Avoid html fragment effect - if (reactiveNode instanceof TemplateNode || reactiveNode instanceof TemplatesNode) { - // TODO more diff - reactiveNode.commitValue( - [ - oldTemplateData[index] as (PWCElementTemplate & PWCElementTemplate[]), - templateData[index] as (PWCElementTemplate & PWCElementTemplate[]), - ], - ); - } else if (!shallowEqual(oldTemplateData[index], templateData[index])) { - reactiveNode.commitValue(templateData[index]); - } - } - } -} - -export const ReactiveNodeMap: ReactiveNodeMapType = { - [NodeType.TEXT]: TextNode, - [NodeType.ATTRIBUTE]: AttributedNode, - [NodeType.TEMPLATE]: TemplateNode, - [NodeType.TEMPLATES]: TemplatesNode, -}; diff --git a/packages/pwc/src/elements/renderElementTemplate.ts b/packages/pwc/src/elements/renderElementTemplate.ts deleted file mode 100644 index cbcea4a..0000000 --- a/packages/pwc/src/elements/renderElementTemplate.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { PWCElement, ReactiveNode, ReactiveNodeMapType, RootElement, TemplateDataItemType } from '../type'; -import { NodeType, PLACEHOLDER_COMMENT_DATA, PWC_PREFIX, TEXT_COMMENT_DATA } from '../constants'; -import { isArray, isTemplate } from '../utils'; -import { throwError } from '../error'; - -// Scan placeholder node, and commit dynamic data to component -export function renderElementTemplate( - fragment: RootElement | Node, - templateData: TemplateDataItemType[], - reactiveNodes: ReactiveNode[], - rootElement: PWCElement, - ReactiveNodeMap: ReactiveNodeMapType, -) { - const nodeIterator = document.createNodeIterator(fragment, NodeFilter.SHOW_COMMENT, { - acceptNode(node) { - if ((node as Comment).data?.includes(PWC_PREFIX)) { - return NodeFilter.FILTER_ACCEPT; - } - return NodeFilter.FILTER_REJECT; - }, - }); - let placeholder: Node; - let index = 0; - - while ((placeholder = nodeIterator.nextNode())) { - const value = templateData[index]; - let type; - - // Insert dynamic text node - if ((placeholder as Comment).data === TEXT_COMMENT_DATA) { - if (isArray(value)) { - type = NodeType.TEMPLATES; - } else if (isTemplate(value)) { - type = NodeType.TEMPLATE; - } else { - type = NodeType.TEXT; - } - } else if ((placeholder as Comment).data === PLACEHOLDER_COMMENT_DATA) { - type = NodeType.ATTRIBUTE; - } - - if (__DEV__) { - if (!type) { - throwError('It is an invalid element template!'); - } - } - - reactiveNodes.push(new ReactiveNodeMap[type](placeholder, rootElement, value)); - - index++; - } -} diff --git a/packages/pwc/src/type.ts b/packages/pwc/src/type.ts index cf9b151..b354c6b 100644 --- a/packages/pwc/src/type.ts +++ b/packages/pwc/src/type.ts @@ -1,18 +1,16 @@ -import { NodeType } from './constants'; - export interface PWCElement extends Element { connectedCallback(): void; disconnectedCallback(): void; attributeChangedCallback(name: string, oldValue: any, newValue: any): void; adoptedCallback(): void; _requestUpdate(): void; + _getChangedProperties(): Set; prototype: PWCElement; new(): PWCElement; } export type RootElement = PWCElement | ShadowRoot; - export interface NormalAttribute { name: string; value: any; @@ -57,19 +55,3 @@ export type ReflectProperties = Map; -export interface ReactiveNode { - reactiveNodes?: ReactiveNode[]; - commitValue: (value: any) => void; -} - -export type ReactiveNodeValue = string | Attributes | PWCElementTemplate[] | PWCElementTemplate; - -interface ReactiveNodeCtor { - new( - commentNode: Comment, - rootElement: PWCElement, - initialValue?: ReactiveNodeValue, - ): ReactiveNode; -} - -export type ReactiveNodeMapType = Record; diff --git a/packages/pwc/src/utils/shallowEqual.ts b/packages/pwc/src/utils/shallowEqual.ts index f38c4d9..1b48fb7 100644 --- a/packages/pwc/src/utils/shallowEqual.ts +++ b/packages/pwc/src/utils/shallowEqual.ts @@ -24,7 +24,7 @@ export function shallowEqual(valueA: any, valueB: any) { for (let index = 0; index < valueA.length; index++) { const itemA = valueA[index]; const itemB = valueB[index]; - if (isEvent(itemA.name)) { + if (isEvent(itemA)) { continue; } if (!is(itemA.value, itemB.value)) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cd4483..a602f8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,19 @@ importers: vite: 2.9.9 vite-plugin-babel: 1.0.0_@babel+core@7.17.10+vite@2.9.9 + examples/condition: + specifiers: + pwc: workspace:* + rollup: ^2.0.0 + vite: ^2.7.2 + vite-plugin-babel: ^1.0.0 + dependencies: + pwc: link:../../packages/pwc + devDependencies: + rollup: 2.72.1 + vite: 2.9.8 + vite-plugin-babel: 1.0.0_@babel+core@7.17.10+vite@2.9.8 + examples/edit-word: specifiers: '@ice/pkg': ^1.0.0-rc.4 From 4d3de26ef801ff9f814a3cd948eb2d8508208029 Mon Sep 17 00:00:00 2001 From: "chenrongyan.cry" Date: Fri, 13 May 2022 18:04:29 +0800 Subject: [PATCH 3/6] chore: fix lint --- packages/pwc/src/elements/part/utils/commitAttributes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pwc/src/elements/part/utils/commitAttributes.ts b/packages/pwc/src/elements/part/utils/commitAttributes.ts index 39b8e09..2f82785 100644 --- a/packages/pwc/src/elements/part/utils/commitAttributes.ts +++ b/packages/pwc/src/elements/part/utils/commitAttributes.ts @@ -170,7 +170,7 @@ export function commitAttributes(element: Element, attrs: Attributes | [Attribut } const { - isAttributeChanged = returnTrue + isAttributeChanged = returnTrue, } = opt || {}; const [prevAttrs, currentAttrs] = attrs as [Attributes, Attributes]; From 7d2516c7041063e0af2f69d3c09936d13bfb4d70 Mon Sep 17 00:00:00 2001 From: "chenrongyan.cry" Date: Mon, 16 May 2022 11:35:44 +0800 Subject: [PATCH 4/6] fix: add global jest --- examples/list/main.js | 87 +++---------------- .../__tests__/createReactive/Map.test.ts | 1 + .../__tests__/createReactive/Set.test.ts | 1 + .../__tests__/createReactive/WeakMap.test.ts | 1 + .../__tests__/createReactive/WeakSet.test.ts | 1 + .../__tests__/createReactive/base.test.ts | 1 + pnpm-lock.yaml | 4 +- 7 files changed, 20 insertions(+), 76 deletions(-) diff --git a/examples/list/main.js b/examples/list/main.js index ec8d519..e0ac845 100644 --- a/examples/list/main.js +++ b/examples/list/main.js @@ -8,88 +8,27 @@ class CustomElement extends HTMLElement { @reactive accessor #list = ['item 1', 'item 2', 'item 3', 'item 4']; - @reactive - accessor #flag = true; - onClick() { this.#list.push('item 5'); } handleItemClick(index) { - console.log(index); this.#list = [...this.#list.slice(0, index), ...this.#list.slice(index + 1)]; } - handleToggle() { - this.#flag = !this.#flag; - } - get template() { - return html`
${this.#flag + ''}
`; - // let result; - // if (this.#flag) { - // result = this.#list.map((item, index) => { - // return html`
${item}
` - // }); - // } else { - // result = html`
${this.#flag + ''}
`; - // } - // return result; - - // const result = html`
- // ${html`${this.#list.map((item, index) => { - // if (item === 'item 2') { - // return null; - // } - // if (item === 'item 3') { - // return [1, 2, 3].map((insideItem) => { - // return html`
this.handleItemClick(index)}>inside list: ${insideItem}
`; - // }); - // } - // return html`
this.handleItemClick(index)}>${item}
`; - // })}`} - //
`; - // console.log(result); - // return result; - - // let result; - // if (this.#flag) { - // result = html`
- // ${this.#flag + ''} - //
`; - // } else { - // result = html`
- // ${html`${this.#list.map((item, index) => { - // if (item === 'item 2') { - // return null; - // } - // if (item === 'item 3') { - // return [1, 2, 3].map((insideItem) => { - // return html`
this.handleItemClick(index)}>inside list: ${insideItem}
`; - // }); - // } - // return html`
this.handleItemClick(index)}>${item}
`; - // })}`} - //
`; - // } - // console.log(result); - // return result; - - // const result = html`${this.#list.map((item, index) => { - // if (item === 'item 2') { - // return null; - // } - // if (item === 'item 3') { - // return [1, 2, 3].map((insideItem) => { - // return html`
this.handleItemClick(index)}> - // inside list: ${insideItem} - //
`; - // }); - // } - // return html`
this.handleItemClick(index)}>${item}
`; - // })}`; - - // console.log(result); - // return result; + return html`
+ ${html`${this.#list.map((item, index) => { + if (item === 'item 2') { + return null; + } + if (item === 'item 3') { + return [1, 2, 3].map((insideItem) => { + return html`
this.handleItemClick(index)}>inside list: ${insideItem}
`; + }); + } + return html`
this.handleItemClick(index)}>${item}
`; + })}`} +
`; } } diff --git a/packages/pwc/src/reactivity/__tests__/createReactive/Map.test.ts b/packages/pwc/src/reactivity/__tests__/createReactive/Map.test.ts index 96e460b..e6b4c72 100644 --- a/packages/pwc/src/reactivity/__tests__/createReactive/Map.test.ts +++ b/packages/pwc/src/reactivity/__tests__/createReactive/Map.test.ts @@ -1,3 +1,4 @@ +import { jest } from '@jest/globals'; import { createReactive } from '../../createReactive'; describe('createReactive/Map', () => { diff --git a/packages/pwc/src/reactivity/__tests__/createReactive/Set.test.ts b/packages/pwc/src/reactivity/__tests__/createReactive/Set.test.ts index 9010954..ef82306 100644 --- a/packages/pwc/src/reactivity/__tests__/createReactive/Set.test.ts +++ b/packages/pwc/src/reactivity/__tests__/createReactive/Set.test.ts @@ -1,3 +1,4 @@ +import { jest } from '@jest/globals'; import { createReactive } from '../../createReactive'; describe('createReactive/Set', () => { diff --git a/packages/pwc/src/reactivity/__tests__/createReactive/WeakMap.test.ts b/packages/pwc/src/reactivity/__tests__/createReactive/WeakMap.test.ts index e725645..47fd279 100644 --- a/packages/pwc/src/reactivity/__tests__/createReactive/WeakMap.test.ts +++ b/packages/pwc/src/reactivity/__tests__/createReactive/WeakMap.test.ts @@ -1,3 +1,4 @@ +import { jest } from '@jest/globals'; import { createReactive } from '../../createReactive'; describe('createReactive/WeakMap', () => { diff --git a/packages/pwc/src/reactivity/__tests__/createReactive/WeakSet.test.ts b/packages/pwc/src/reactivity/__tests__/createReactive/WeakSet.test.ts index bba7cff..93f99d5 100644 --- a/packages/pwc/src/reactivity/__tests__/createReactive/WeakSet.test.ts +++ b/packages/pwc/src/reactivity/__tests__/createReactive/WeakSet.test.ts @@ -1,3 +1,4 @@ +import { jest } from '@jest/globals'; import { createReactive } from '../../createReactive'; describe('createReactive/Set', () => { diff --git a/packages/pwc/src/reactivity/__tests__/createReactive/base.test.ts b/packages/pwc/src/reactivity/__tests__/createReactive/base.test.ts index b5be9e3..882a9c3 100644 --- a/packages/pwc/src/reactivity/__tests__/createReactive/base.test.ts +++ b/packages/pwc/src/reactivity/__tests__/createReactive/base.test.ts @@ -1,3 +1,4 @@ +import { jest } from '@jest/globals'; import { getProperties, toRaw } from '../../../utils'; import { createReactive } from '../../createReactive'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a602f8b..d03e0f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,8 +91,8 @@ importers: pwc: link:../../packages/pwc devDependencies: rollup: 2.72.1 - vite: 2.9.8 - vite-plugin-babel: 1.0.0_@babel+core@7.17.10+vite@2.9.8 + vite: 2.9.9 + vite-plugin-babel: 1.0.0_@babel+core@7.17.10+vite@2.9.9 examples/edit-word: specifiers: From 0e18a5586005e4152439df934ab414d50d4bd889 Mon Sep 17 00:00:00 2001 From: "chenrongyan.cry" Date: Mon, 16 May 2022 14:51:53 +0800 Subject: [PATCH 5/6] fix: add test cases and some fixs --- examples/condition/main.js | 24 +++-- jest.config.js | 9 +- .../elements/__tests__/HTMLElement.test.ts | 90 +++++++++++++++++-- .../__tests__/part/commitAttributes.test.ts | 1 + packages/pwc/src/elements/part/attributes.ts | 36 ++++---- .../elements/part/utils/commitAttributes.ts | 23 +++-- .../elements/part/utils/commitTemplates.ts | 4 +- .../src/elements/reactiveElementFactory.ts | 4 +- packages/pwc/src/index.ts | 2 +- .../__tests__/createReactive/base.test.ts | 21 +---- .../src/reactivity/__tests__/methods.test.ts | 33 +++++++ .../src/reactivity/__tests__/reactive.test.ts | 2 +- packages/pwc/src/reactivity/baseProxy.ts | 6 +- .../pwc/src/reactivity/collectionProxy.ts | 6 +- packages/pwc/src/reactivity/createReactive.ts | 3 +- .../methods.ts} | 6 +- packages/pwc/src/reactivity/reactive.ts | 14 ++- packages/pwc/src/utils/index.ts | 2 - packages/pwc/src/utils/shallowEqual.ts | 36 -------- 19 files changed, 217 insertions(+), 105 deletions(-) create mode 100644 packages/pwc/src/reactivity/__tests__/methods.test.ts rename packages/pwc/src/{utils/reactiveMethods.ts => reactivity/methods.ts} (68%) delete mode 100644 packages/pwc/src/utils/shallowEqual.ts diff --git a/examples/condition/main.js b/examples/condition/main.js index 1133fe2..ae930f7 100644 --- a/examples/condition/main.js +++ b/examples/condition/main.js @@ -1,5 +1,15 @@ import { reactive, customElement, html } from 'pwc'; +@customElement('child-element') +class ChildElement extends HTMLElement { + @reactive + accessor data = { foo: 0 } + get template() { + console.log('>>>'); + return html`
${this.data.foo}
`; + } +} + @customElement('custom-element') class CustomElement extends HTMLElement { @reactive @@ -8,6 +18,9 @@ class CustomElement extends HTMLElement { @reactive accessor #icon = ''; + @reactive + accessor #data = { foo: 1 } + handleClick() { console.log('click'); @@ -16,12 +29,13 @@ class CustomElement extends HTMLElement { } get template() { - return html`
-

Condition is ${this.#condition}

+ const result = html`
+

Condition is ${this.#condition + ''}

${this.#condition ? html`

True Condition${this.#icon}

` : html`

False Condition${this.#icon}

`} +
`; + + console.log(result); + return result; } } - - -//

vs

\ No newline at end of file diff --git a/jest.config.js b/jest.config.js index 2f5af4d..52452bd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,14 @@ module.exports = { coverageDirectory: './coverage/', collectCoverage: true, - collectCoverageFrom: ['packages/pwc/src/**/*.{js,ts}', 'packages/pwc-compiler/esm/**/*.{js,ts}', '!packages/**/*.d.ts', '!packages/**/type.ts', '!packages/*/src/index.{js,ts}'], + collectCoverageFrom: [ + 'packages/pwc/src/**/*.{js,ts}', + 'packages/pwc-compiler/esm/**/*.{js,ts}', + '!packages/**/*.d.ts', + '!packages/**/type.ts', + '!packages/*/src/index.{js,ts}', + '!packages/*/src/utils/*.{js,ts}' + ], coveragePathIgnorePatterns: ['/node_modules/'], roots: ['/packages'], testPathIgnorePatterns: ['/node_modules/', '/cjs/', '/esm/', '/es2017/', '/dist/', '.d.ts'], diff --git a/packages/pwc/src/elements/__tests__/HTMLElement.test.ts b/packages/pwc/src/elements/__tests__/HTMLElement.test.ts index 71036f7..ab39917 100644 --- a/packages/pwc/src/elements/__tests__/HTMLElement.test.ts +++ b/packages/pwc/src/elements/__tests__/HTMLElement.test.ts @@ -156,7 +156,8 @@ describe('Render HTMLElement', () => { const container = document.getElementById('reactive-container'); container.click(); - + // @ts-ignore + expect(element._getChangedProperties()).toEqual(new Set(['#data', '#text', '#className'])); await nextTick(); expect(element.innerHTML).toEqual( '
hello? - jack!
', @@ -264,32 +265,48 @@ describe('Render nested components', () => { it('any reactive data should trigger the update of child components', async () => { const parentBtn = document.getElementById('parent-btn'); - const childElement = document.getElementById('child-container'); - expect(childElement.innerHTML).toEqual( + const childContainer = document.getElementById('child-container'); + expect(childContainer.innerHTML).toEqual( '\n
Hello - World -
\n ', ); expect(mockChildFn).toBeCalledTimes(1); + const parentElement = element; + const childElement = document.getElementsByTagName('child-element')[0]; + // @ts-ignore + expect(parentElement._getChangedProperties()).toEqual(new Set()); + // @ts-ignore + expect(childElement._getChangedProperties()).toEqual(new Set()); + // primity type parentBtn.click(); + // @ts-ignore + expect(parentElement._getChangedProperties()).toEqual(new Set(['#title'])); + await nextTick(); - expect(childElement.innerHTML).toEqual( + expect(childContainer.innerHTML).toEqual( '\n
Hello! - World -
\n ', ); expect(mockChildFn).toBeCalledTimes(2); // object type parentBtn.click(); + // @ts-ignore + expect(parentElement._getChangedProperties()).toEqual(new Set(['#data'])); + await nextTick(); - expect(childElement.innerHTML).toEqual( + expect(childContainer.innerHTML).toEqual( '\n
Hello! - World! -
\n ', ); expect(mockChildFn).toBeCalledTimes(3); // array type parentBtn.click(); + // @ts-ignore + expect(parentElement._getChangedProperties()).toEqual(new Set(['#items'])); + await nextTick(); - expect(childElement.innerHTML).toEqual( + expect(childContainer.innerHTML).toEqual( '\n
Hello! - World! - 2
\n ', ); expect(mockChildFn).toBeCalledTimes(4); @@ -323,6 +340,65 @@ describe('Render nested components', () => { ); expect(mockChildFn).toBeCalledTimes(6); }); + + it('keep track of changed properties', async () => { + const mockFn1 = jest.fn(); + const mockFn2 = jest.fn(); + @customElement('child-element-1') + class ChildElement1 extends HTMLElement { + @reactive + accessor data = {} + get template() { + return mockFn1(); + } + } + + @customElement('child-element-2') + class ChildElement2 extends HTMLElement { + @reactive + accessor data = {} + get template() { + return mockFn2(); + } + } + + + @customElement('track-element') + class ParentElement extends HTMLElement { + @reactive + accessor #data1 = { foo: 1 } + + @reactive + accessor #data2 = { foo: 2}; + + handleClick() { + this.#data2.foo = 3; + } + + get template() { + return html` +
Click
+ + + ` + } + } + + const element = document.createElement('track-element'); + document.body.append(element); + const btn = document.getElementById('track-btn'); + + expect(mockFn1).toBeCalledTimes(1); + expect(mockFn2).toBeCalledTimes(1); + + btn.click(); + // @ts-ignore + expect(element._getChangedProperties()).toEqual(new Set(['#data2'])); + + await nextTick(); + expect(mockFn1).toBeCalledTimes(1); + expect(mockFn2).toBeCalledTimes(2); + }); }); describe('render multiple kinds template', () => { @@ -585,3 +661,5 @@ describe('render with rax', () => { ); }); }); + + diff --git a/packages/pwc/src/elements/__tests__/part/commitAttributes.test.ts b/packages/pwc/src/elements/__tests__/part/commitAttributes.test.ts index 53cf348..b98c4ab 100644 --- a/packages/pwc/src/elements/__tests__/part/commitAttributes.test.ts +++ b/packages/pwc/src/elements/__tests__/part/commitAttributes.test.ts @@ -255,3 +255,4 @@ describe('Set/Update/Remove element attribute/property/event handler', () => { expect(svg.getAttribute('height')).toEqual('200'); }); }); + diff --git a/packages/pwc/src/elements/part/attributes.ts b/packages/pwc/src/elements/part/attributes.ts index 4a898d3..5308d2a 100644 --- a/packages/pwc/src/elements/part/attributes.ts +++ b/packages/pwc/src/elements/part/attributes.ts @@ -1,7 +1,21 @@ import { Attributes, NormalAttribute, PWCElement } from '../../type'; import { commitAttributes } from './utils/commitAttributes'; import { BasePart } from './base'; -import { getProperties } from '../../utils'; +import { getProperties } from '../../reactivity/methods'; + +export function genIsAttributeChanged(changedProperties: Set) { + return function(attr: NormalAttribute): boolean { + const { value } = attr; + const properties = getProperties(value); + + for (let prop of properties) { + if (changedProperties.has(prop)) { + return true; + } + } + return false; + }; +} export class AttributesPart extends BasePart { #el: Element; @@ -28,30 +42,20 @@ export class AttributesPart extends BasePart { } commitValue([prev, current]: [Attributes, Attributes]) { - this.commitAttributes([prev, current]); + const updated = this.commitAttributes([prev, current]); // Any updating should trigger the child components's update method - if (this.#elIsCustom && (this.#el as PWCElement)._requestUpdate) { + if (this.#elIsCustom && (this.#el as PWCElement)._requestUpdate && updated) { (this.#el as PWCElement)._requestUpdate(); } } - commitAttributes(value: Attributes | [Attributes, Attributes], isInitial = false) { + commitAttributes(value: Attributes | [Attributes, Attributes], isInitial = false): boolean { const changedProperties = this.rootElement._getChangedProperties(); - function isAttributeChanged(attr: NormalAttribute): boolean { - const { value } = attr; - const properties = getProperties(value); - - for (let prop of properties) { - if (changedProperties.has(prop)) { - return true; - } - } - return false; - } + const isAttributeChanged = genIsAttributeChanged(changedProperties); - commitAttributes(this.#el, value, { + return commitAttributes(this.#el, value, { isInitial, isSVG: this.#elIsSvg, rootElement: this.rootElement, diff --git a/packages/pwc/src/elements/part/utils/commitAttributes.ts b/packages/pwc/src/elements/part/utils/commitAttributes.ts index 2f82785..66c6c8c 100644 --- a/packages/pwc/src/elements/part/utils/commitAttributes.ts +++ b/packages/pwc/src/elements/part/utils/commitAttributes.ts @@ -1,6 +1,6 @@ -import { isEvent } from '../../../utils/isEvent'; +import { toRaw } from '../../../reactivity/methods'; import type { Attribute, Attributes, NormalAttribute, PWCElement } from '../../../type'; -import { is, isArray, toRaw } from '../../../utils'; +import { is, isArray, isEvent } from '../../../utils'; type IsAttributeChanged = (attr: NormalAttribute) => boolean; @@ -8,7 +8,7 @@ type Options = { isInitial?: boolean; rootElement?: PWCElement; isSVG?: boolean; - isAttributeChanged: IsAttributeChanged; + isAttributeChanged?: IsAttributeChanged; }; type Handler = (...args: any[]) => any; @@ -29,7 +29,7 @@ function getHandler(handler: Handler, rootElement?: PWCElement): Handler { } function isAttributes(attrs: Attributes | [Attributes, Attributes]): boolean { - return attrs.length === 0 || !isArray(attrs[0]); + return !isArray(attrs[0]); } @@ -53,7 +53,7 @@ function diffAttribute(prevAttr: Attribute, currentAttr: Attribute, isAttributeC return DiffResult.RESET; } if (!isEvent(currentAttr)) { - if (is(prevAttr, currentAttr) && !isAttributeChanged(currentAttr)) { + if (is(prevAttr.value, currentAttr.value) && !isAttributeChanged(currentAttr)) { return DiffResult.SAME; } return DiffResult.CHANGED; @@ -163,10 +163,18 @@ function removeAttributes(element: Element, attrs: Attributes, opt?: Options) { } } -export function commitAttributes(element: Element, attrs: Attributes | [Attributes, Attributes], opt?: Options) { +// Commit attributes, return a boolean, means if updated +export function commitAttributes( + element: Element, + attrs: Attributes | [Attributes, Attributes], + opt?: Options +): boolean { + if (attrs.length === 0) { + return false; + } if (isAttributes(attrs)) { setAttributes(element, attrs as Attributes, opt); - return; + return attrs.length > 0; } const { @@ -181,4 +189,5 @@ export function commitAttributes(element: Element, attrs: Attributes | [Attribut removeAttributes(element, removed, opt); setAttributes(element, changed, opt); + return changed.length + removed.length > 0; } diff --git a/packages/pwc/src/elements/part/utils/commitTemplates.ts b/packages/pwc/src/elements/part/utils/commitTemplates.ts index 7b6cb37..c6f93d6 100644 --- a/packages/pwc/src/elements/part/utils/commitTemplates.ts +++ b/packages/pwc/src/elements/part/utils/commitTemplates.ts @@ -45,7 +45,7 @@ export function commitTemplates( [prev, current]: [PWCElementTemplate, PWCElementTemplate], dynamicNode: DynamicNode, rootElement: PWCElement, -) { +): boolean { const { templateString: prevTemplateString, templateData: prevTemplateData, @@ -116,4 +116,6 @@ export function commitTemplates( // Passing valus dynamicNode.part = part; dynamicNode.children = children; + + return true; } diff --git a/packages/pwc/src/elements/reactiveElementFactory.ts b/packages/pwc/src/elements/reactiveElementFactory.ts index 0834320..8a3f46f 100644 --- a/packages/pwc/src/elements/reactiveElementFactory.ts +++ b/packages/pwc/src/elements/reactiveElementFactory.ts @@ -40,6 +40,7 @@ export default (Definition: PWCElement) => { this.appendChild(commentNode); this.#dynamicPart = new TemplatePart(commentNode, this, this.#currentTemplate as PWCElementTemplate); this.#initialized = true; + this.#reactive.clearChangedProperties(); } } disconnectedCallback() {} @@ -56,6 +57,7 @@ export default (Definition: PWCElement) => { // The root reactive node must be TemplateNode this.#dynamicPart.commitValue([this.#currentTemplate, newPWCElementTemplate]); this.#currentTemplate = newPWCElementTemplate; + this.#reactive.clearChangedProperties(); } _requestUpdate(): void { @@ -85,7 +87,7 @@ export default (Definition: PWCElement) => { } _getChangedProperties(): Set { - return this.#reactive.changedProperties; + return this.#reactive.getChangedProperties(); } }; }; diff --git a/packages/pwc/src/index.ts b/packages/pwc/src/index.ts index a48b82f..c7008d1 100644 --- a/packages/pwc/src/index.ts +++ b/packages/pwc/src/index.ts @@ -2,6 +2,6 @@ import './elements'; export { nextTick } from './elements/sheduler'; export * from './decorators'; -export { toRaw } from './utils'; +export { toRaw } from './reactivity/methods'; export { compileTemplateInRuntime as html } from '@pwc/compiler/compileTemplateInRuntime'; diff --git a/packages/pwc/src/reactivity/__tests__/createReactive/base.test.ts b/packages/pwc/src/reactivity/__tests__/createReactive/base.test.ts index 882a9c3..eada53a 100644 --- a/packages/pwc/src/reactivity/__tests__/createReactive/base.test.ts +++ b/packages/pwc/src/reactivity/__tests__/createReactive/base.test.ts @@ -1,5 +1,4 @@ import { jest } from '@jest/globals'; -import { getProperties, toRaw } from '../../../utils'; import { createReactive } from '../../createReactive'; describe('createReactive', () => { @@ -42,22 +41,4 @@ describe('createReactive', () => { reactived[2].foo = 3; expect(mockCallback.mock.calls.length).toBe(4); }); - - it('toRaw', () => { - const mockCallback = jest.fn(() => {}); - const source = { foo: 1 }; - const reactived = createReactive(source, propName, mockCallback); - - const raw = toRaw(reactived); - expect(raw).toBe(source); - }); - - it('getProperties', () => { - const mockCallback = jest.fn(() => {}); - const source = { obj: { foo: 1 } }; - const reactived = createReactive(source, propName, mockCallback); - - expect(getProperties(reactived)).toEqual(new Set([propName])); - expect(getProperties(reactived.obj)).toEqual(new Set([propName])); - }); -}); \ No newline at end of file +}); diff --git a/packages/pwc/src/reactivity/__tests__/methods.test.ts b/packages/pwc/src/reactivity/__tests__/methods.test.ts new file mode 100644 index 0000000..79a7711 --- /dev/null +++ b/packages/pwc/src/reactivity/__tests__/methods.test.ts @@ -0,0 +1,33 @@ +import { jest } from '@jest/globals'; +import { createReactive } from '../createReactive'; +import { toRaw, getProperties, isReactive } from '../methods'; + +describe('reactive methods', () => { + const propName = 'prop'; + it('toRaw', () => { + const mockCallback = jest.fn(() => {}); + const source = { foo: 1 }; + const reactived = createReactive(source, propName, mockCallback); + + const raw = toRaw(reactived); + expect(raw).toBe(source); + }); + + it('getProperties', () => { + const mockCallback = jest.fn(() => {}); + const source = { obj: { foo: 1 } }; + const reactived = createReactive(source, propName, mockCallback); + + expect(getProperties(reactived)).toEqual(new Set([propName])); + expect(getProperties(reactived.obj)).toEqual(new Set([propName])); + }); + + it('isReactive', () => { + const mockCallback = jest.fn(() => {}); + const source = { obj: { foo: 1 } }; + const reactived = createReactive(source, propName, mockCallback); + + expect(isReactive(reactived)).toBe(true); + expect(isReactive(source)).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/pwc/src/reactivity/__tests__/reactive.test.ts b/packages/pwc/src/reactivity/__tests__/reactive.test.ts index 920fa56..71517d2 100644 --- a/packages/pwc/src/reactivity/__tests__/reactive.test.ts +++ b/packages/pwc/src/reactivity/__tests__/reactive.test.ts @@ -1,5 +1,5 @@ +import { toRaw } from '../methods'; import { Reactive } from '../reactive'; -import { toRaw } from '../../utils'; class MockReactiveElement { #initialized = false; diff --git a/packages/pwc/src/reactivity/baseProxy.ts b/packages/pwc/src/reactivity/baseProxy.ts index d167999..dcbcb74 100644 --- a/packages/pwc/src/reactivity/baseProxy.ts +++ b/packages/pwc/src/reactivity/baseProxy.ts @@ -1,6 +1,7 @@ import { ReactiveFlags } from '../constants'; -import { hasOwnProperty, isArray, isObject, toRaw } from '../utils'; +import { hasOwnProperty, isArray, isObject } from '../utils'; import { toReactive } from './createReactive'; +import { toRaw } from './methods'; import { forwardTracks, getPropertyNames, runHandlers } from './track'; function get( @@ -14,6 +15,9 @@ function get( if (key === ReactiveFlags.RAW) { return target; } + if (key === ReactiveFlags.IS_REACTIVE) { + return true; + } const result = Reflect.get(target, key, receiver); if (isObject(result)) { forwardTracks(target, result); diff --git a/packages/pwc/src/reactivity/collectionProxy.ts b/packages/pwc/src/reactivity/collectionProxy.ts index e2873a6..08321c4 100644 --- a/packages/pwc/src/reactivity/collectionProxy.ts +++ b/packages/pwc/src/reactivity/collectionProxy.ts @@ -1,7 +1,8 @@ import { ReactiveFlags } from '../constants'; -import { hasOwnProperty, isObject, toRaw, isMap } from '../utils'; +import { hasOwnProperty, isObject, isMap } from '../utils'; import { toReactive } from './createReactive'; import { forwardTracks, getPropertyNames, runHandlers } from './track'; +import { toRaw } from './methods'; export type CollectionTypes = IterableCollections | WeakCollections; @@ -206,6 +207,9 @@ export function createCollectionProxy(target: any) { if (key === ReactiveFlags.RAW) { return target; } + if (key === ReactiveFlags.IS_REACTIVE) { + return true; + } if (hasOwnProperty(instrumentations, key) && key in target) { return Reflect.get(instrumentations, key, receiver); diff --git a/packages/pwc/src/reactivity/createReactive.ts b/packages/pwc/src/reactivity/createReactive.ts index 354f1c8..0447b92 100644 --- a/packages/pwc/src/reactivity/createReactive.ts +++ b/packages/pwc/src/reactivity/createReactive.ts @@ -1,4 +1,5 @@ -import { isObject, toRaw, toRawType } from '../utils'; +import { isObject, toRawType } from '../utils'; +import { toRaw } from './methods'; import { createBaseProxy } from './baseProxy'; import { createCollectionProxy } from './collectionProxy'; import { keepTrack } from './track'; diff --git a/packages/pwc/src/utils/reactiveMethods.ts b/packages/pwc/src/reactivity/methods.ts similarity index 68% rename from packages/pwc/src/utils/reactiveMethods.ts rename to packages/pwc/src/reactivity/methods.ts index c5fd78d..5061fa5 100644 --- a/packages/pwc/src/utils/reactiveMethods.ts +++ b/packages/pwc/src/reactivity/methods.ts @@ -6,10 +6,10 @@ export function toRaw(observed: T): T { } // get the propNames which the reactive obj created from -export function getProperties(observed): Set | undefined { - return observed && observed[ReactiveFlags.PROPERTY] ? observed[ReactiveFlags.PROPERTY] : undefined; +export function getProperties(observed): Set { + return observed && observed[ReactiveFlags.PROPERTY] ? observed[ReactiveFlags.PROPERTY] : new Set(); } export function isReactive(observed): boolean { - return observed && observed[ReactiveFlags.IS_REACTIVE]; + return !!(observed && observed[ReactiveFlags.IS_REACTIVE]); } diff --git a/packages/pwc/src/reactivity/reactive.ts b/packages/pwc/src/reactivity/reactive.ts index 20ae036..a9a0625 100644 --- a/packages/pwc/src/reactivity/reactive.ts +++ b/packages/pwc/src/reactivity/reactive.ts @@ -13,7 +13,7 @@ interface ReactiveType { } export class Reactive implements ReactiveType { - changedProperties: Set = new Set(); + #changedProperties: Set = new Set(); static getKey(key: string): string { return `#_${key}`; @@ -26,7 +26,7 @@ export class Reactive implements ReactiveType { } requestUpdate(prop: string) { - this.changedProperties.add(prop); + this.#changedProperties.add(prop); this.#element?._requestUpdate(); } @@ -49,9 +49,19 @@ export class Reactive implements ReactiveType { if (forceUpdate) { this.requestUpdate(prop); + } else { + this.#changedProperties.add(prop); } } + getChangedProperties() { + return this.#changedProperties; + } + + clearChangedProperties() { + this.#changedProperties.clear(); + } + #setReactiveValue(prop: string, value: unknown) { if (isArray(value) || isPlainObject(value)) { this.#createReactiveProperty(prop, value); diff --git a/packages/pwc/src/utils/index.ts b/packages/pwc/src/utils/index.ts index ec542ef..3652fb7 100644 --- a/packages/pwc/src/utils/index.ts +++ b/packages/pwc/src/utils/index.ts @@ -1,8 +1,6 @@ export * from './common'; export * from './isEvent'; -export * from './shallowEqual'; export * from './generateUid'; export * from './checkTypes'; export * from './shallowClone'; -export * from './reactiveMethods'; diff --git a/packages/pwc/src/utils/shallowEqual.ts b/packages/pwc/src/utils/shallowEqual.ts deleted file mode 100644 index 1b48fb7..0000000 --- a/packages/pwc/src/utils/shallowEqual.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { hasOwnProperty, is } from './common'; -import { isEvent } from './isEvent'; -import { isPrimitive } from './checkTypes'; - -export function shallowEqual(valueA: any, valueB: any) { - if (typeof valueA !== typeof valueB) { - return false; - } - // text node - if (isPrimitive(valueB)) { - return valueA === valueB; - } - // attribute node - const keysA = Object.keys(valueA); - const keysB = Object.keys(valueB); - if (keysA.length !== keysB.length) { - return false; - } - - for (const val of keysA) { - if (!hasOwnProperty(valueB, val) || !isEvent(valueA[val]) || !is(valueA[val], valueB[val])) { - return false; - } - for (let index = 0; index < valueA.length; index++) { - const itemA = valueA[index]; - const itemB = valueB[index]; - if (isEvent(itemA)) { - continue; - } - if (!is(itemA.value, itemB.value)) { - return false; - } - } - } - return true; -} From b14d2972a8df7ff81e93d7977e1ee1032e597876 Mon Sep 17 00:00:00 2001 From: "chenrongyan.cry" Date: Mon, 16 May 2022 14:53:56 +0800 Subject: [PATCH 6/6] chore: fix lint --- jest.config.js | 2 +- packages/pwc/src/elements/part/attributes.ts | 2 +- packages/pwc/src/elements/part/utils/commitAttributes.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/jest.config.js b/jest.config.js index 52452bd..fac9c6b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,7 +7,7 @@ module.exports = { '!packages/**/*.d.ts', '!packages/**/type.ts', '!packages/*/src/index.{js,ts}', - '!packages/*/src/utils/*.{js,ts}' + '!packages/*/src/utils/*.{js,ts}', ], coveragePathIgnorePatterns: ['/node_modules/'], roots: ['/packages'], diff --git a/packages/pwc/src/elements/part/attributes.ts b/packages/pwc/src/elements/part/attributes.ts index 5308d2a..21a9e12 100644 --- a/packages/pwc/src/elements/part/attributes.ts +++ b/packages/pwc/src/elements/part/attributes.ts @@ -4,7 +4,7 @@ import { BasePart } from './base'; import { getProperties } from '../../reactivity/methods'; export function genIsAttributeChanged(changedProperties: Set) { - return function(attr: NormalAttribute): boolean { + return function (attr: NormalAttribute): boolean { const { value } = attr; const properties = getProperties(value); diff --git a/packages/pwc/src/elements/part/utils/commitAttributes.ts b/packages/pwc/src/elements/part/utils/commitAttributes.ts index 66c6c8c..657ecf0 100644 --- a/packages/pwc/src/elements/part/utils/commitAttributes.ts +++ b/packages/pwc/src/elements/part/utils/commitAttributes.ts @@ -167,7 +167,7 @@ function removeAttributes(element: Element, attrs: Attributes, opt?: Options) { export function commitAttributes( element: Element, attrs: Attributes | [Attributes, Attributes], - opt?: Options + opt?: Options, ): boolean { if (attrs.length === 0) { return false;