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/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..ae930f7 --- /dev/null +++ b/examples/condition/main.js @@ -0,0 +1,41 @@ +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 + accessor #condition = true; + + @reactive + accessor #icon = ''; + + @reactive + accessor #data = { foo: 1 } + + + handleClick() { + console.log('click'); + this.#condition = !this.#condition; + this.#icon += '!'; + } + + get template() { + const result = html`
+

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

+ ${this.#condition ? html`

True Condition${this.#icon}

` : html`

False Condition${this.#icon}

`} + +
`; + + console.log(result); + return result; + } +} 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..e0ac845 100644 --- a/examples/list/main.js +++ b/examples/list/main.js @@ -17,18 +17,18 @@ class CustomElement extends HTMLElement { } 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`
+ ${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/jest.config.js b/jest.config.js index 2f5af4d..fac9c6b 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/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/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/elements/__tests__/HTMLElement.test.ts b/packages/pwc/src/elements/__tests__/HTMLElement.test.ts index 82bcd93..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', () => { @@ -443,7 +519,7 @@ describe('render multiple kinds template', () => { document.body.appendChild(el2); expect(el2.innerHTML).toEqual( - '132', + '132', ); @customElement('hybrid-list') @@ -493,7 +569,7 @@ describe('render multiple kinds template', () => { inside list: 2
inside list: 3 -
item4
`); +
item4
`); const item3 = document.getElementsByClassName('item3')[0]; item3.click(); await nextTick(); @@ -585,3 +661,5 @@ describe('render with rax', () => { ); }); }); + + 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..b98c4ab 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,22 @@ 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..21a9e12 --- /dev/null +++ b/packages/pwc/src/elements/part/attributes.ts @@ -0,0 +1,65 @@ +import { Attributes, NormalAttribute, PWCElement } from '../../type'; +import { commitAttributes } from './utils/commitAttributes'; +import { BasePart } from './base'; +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; + #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]) { + const updated = this.commitAttributes([prev, current]); + + // Any updating should trigger the child components's update method + if (this.#elIsCustom && (this.#el as PWCElement)._requestUpdate && updated) { + (this.#el as PWCElement)._requestUpdate(); + } + } + + commitAttributes(value: Attributes | [Attributes, Attributes], isInitial = false): boolean { + const changedProperties = this.rootElement._getChangedProperties(); + + const isAttributeChanged = genIsAttributeChanged(changedProperties); + + return 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..657ecf0 --- /dev/null +++ b/packages/pwc/src/elements/part/utils/commitAttributes.ts @@ -0,0 +1,193 @@ +import { toRaw } from '../../../reactivity/methods'; +import type { Attribute, Attributes, NormalAttribute, PWCElement } from '../../../type'; +import { is, isArray, isEvent } 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 !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.value, currentAttr.value) && !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); + } + } +} + +// 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 attrs.length > 0; + } + + 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); + 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 new file mode 100644 index 0000000..c6f93d6 --- /dev/null +++ b/packages/pwc/src/elements/part/utils/commitTemplates.ts @@ -0,0 +1,121 @@ +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, +): boolean { + 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; + + return true; +} 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..8a3f46f 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,19 +33,14 @@ 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; + this.#reactive.clearChangedProperties(); } } disconnectedCallback() {} @@ -59,11 +53,11 @@ 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; + this.#reactive.clearChangedProperties(); } _requestUpdate(): void { @@ -91,5 +85,9 @@ export default (Definition: PWCElement) => { _getReflectProperties() { return this.#reflectProperties; } + + _getChangedProperties(): Set { + return this.#reactive.getChangedProperties(); + } }; }; 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/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/Map.test.ts b/packages/pwc/src/reactivity/__tests__/createReactive/Map.test.ts new file mode 100644 index 0000000..e6b4c72 --- /dev/null +++ b/packages/pwc/src/reactivity/__tests__/createReactive/Map.test.ts @@ -0,0 +1,111 @@ +import { jest } from '@jest/globals'; +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..ef82306 --- /dev/null +++ b/packages/pwc/src/reactivity/__tests__/createReactive/Set.test.ts @@ -0,0 +1,81 @@ +import { jest } from '@jest/globals'; +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..47fd279 --- /dev/null +++ b/packages/pwc/src/reactivity/__tests__/createReactive/WeakMap.test.ts @@ -0,0 +1,36 @@ +import { jest } from '@jest/globals'; +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..93f99d5 --- /dev/null +++ b/packages/pwc/src/reactivity/__tests__/createReactive/WeakSet.test.ts @@ -0,0 +1,31 @@ +import { jest } from '@jest/globals'; +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..eada53a --- /dev/null +++ b/packages/pwc/src/reactivity/__tests__/createReactive/base.test.ts @@ -0,0 +1,44 @@ +import { jest } from '@jest/globals'; +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); + }); +}); 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 new file mode 100644 index 0000000..dcbcb74 --- /dev/null +++ b/packages/pwc/src/reactivity/baseProxy.ts @@ -0,0 +1,70 @@ +import { ReactiveFlags } from '../constants'; +import { hasOwnProperty, isArray, isObject } from '../utils'; +import { toReactive } from './createReactive'; +import { toRaw } from './methods'; +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; + } + if (key === ReactiveFlags.IS_REACTIVE) { + return true; + } + 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..08321c4 --- /dev/null +++ b/packages/pwc/src/reactivity/collectionProxy.ts @@ -0,0 +1,220 @@ +import { ReactiveFlags } from '../constants'; +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; + +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 (key === ReactiveFlags.IS_REACTIVE) { + return true; + } + + 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..0447b92 --- /dev/null +++ b/packages/pwc/src/reactivity/createReactive.ts @@ -0,0 +1,45 @@ +import { isObject, toRawType } from '../utils'; +import { toRaw } from './methods'; +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/methods.ts b/packages/pwc/src/reactivity/methods.ts new file mode 100644 index 0000000..5061fa5 --- /dev/null +++ b/packages/pwc/src/reactivity/methods.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 { + return observed && observed[ReactiveFlags.PROPERTY] ? observed[ReactiveFlags.PROPERTY] : new Set(); +} + +export function isReactive(observed): boolean { + return !!(observed && observed[ReactiveFlags.IS_REACTIVE]); +} diff --git a/packages/pwc/src/reactivity/reactive.ts b/packages/pwc/src/reactivity/reactive.ts index 60f3d00..a9a0625 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,10 +48,20 @@ export class Reactive implements ReactiveType { } if (forceUpdate) { - this.requestUpdate(); + 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); @@ -65,6 +77,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/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/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..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 './toRaw'; diff --git a/packages/pwc/src/utils/shallowEqual.ts b/packages/pwc/src/utils/shallowEqual.ts deleted file mode 100644 index d1fc83a..0000000 --- a/packages/pwc/src/utils/shallowEqual.ts +++ /dev/null @@ -1,27 +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; - } - } - - 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cd4483..d03e0f3 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.9 + vite-plugin-babel: 1.0.0_@babel+core@7.17.10+vite@2.9.9 + examples/edit-word: specifiers: '@ice/pkg': ^1.0.0-rc.4