From 07e2ce09b48f1ba302c17e3261c430c4853657a8 Mon Sep 17 00:00:00 2001 From: Hoikan <408255371@qq.com> Date: Mon, 19 Feb 2024 06:29:16 +0000 Subject: [PATCH] !148 feat(no-vdom): onMount * feat(no-vdom): onMount --- packages/inula-novdom/README.md | 562 ++++++++++++++++++ packages/inula-novdom/src/core.ts | 13 +- packages/inula-novdom/tests/env.test.tsx | 4 - .../inula-novdom/tests/lifecycle.test.tsx | 143 +++++ packages/inula-novdom/tests/ref.test.ts | 43 +- packages/inula-novdom/tests/utils.ts | 29 + packages/inula-reactive/src/RNodeCreator.ts | 6 +- 7 files changed, 786 insertions(+), 14 deletions(-) create mode 100644 packages/inula-novdom/README.md create mode 100644 packages/inula-novdom/tests/lifecycle.test.tsx diff --git a/packages/inula-novdom/README.md b/packages/inula-novdom/README.md new file mode 100644 index 00000000..d90523e3 --- /dev/null +++ b/packages/inula-novdom/README.md @@ -0,0 +1,562 @@ +# **Templating** + +## JSX + +### template + +JSX整体编译为template模板,动态部分通过insert插入。 +template字符串不进行省略(end tag)。待讨论 + +```jsx +
Count value is .
+ +// solid +const $tmpl = /*#__PURE__*/ $$template(`
Count value is .`); + +// inula +const $tmpl = /*#__PURE__*/ $$template(`
Count value is .
`); +``` + +命名规则 +对于dom的变量,前缀用`$`,后缀用`_number`,用dom类型作为变量名。待讨论 + +```jsx +const $tmpl = /*#__PURE__*/ $$template(`
Count value is .`); +const $div = $tmpl(), // 使用$div + $text = $div.firstChild, // 使用$text +``` + +**内部函数用`$$`作为前缀** + +### insert + +```jsx +const $tmpl = /*#__PURE__*/ $$template(`
Count value is .`); +const $div = $tmpl(), + $text = $div.firstChild, + $text_1 = $text.nextSibling, + $$insert +($div, count, $text_1); +return $div; +``` +## Class +### inline class +直接修改dom的`className` +```jsx + /** + * 源码: + * const Comp = () => { + * const color = 'red'; + * return
Count value is 0.
; + * } + */ + // 编译后: +const $tmpl = /*#__PURE__*/ $$template('
Count value is 0.'); +const Comp = () => { + const color = 'red'; + return (() => { + const _el$ = $tmpl(); + $$effect(() => (_el$.className = color)); + return _el$; + })(); +}; +render(() => $$runComponent(Comp, {}), container); +```` + +### Object class +通过`$$className`设置class +```jsx + /** + * 源码: + * const Comp = () => { + * return
Count value is 0.
; + * } + */ + // 编译后: + const $tmpl = /*#__PURE__*/ $$template('
Count value is 0.'); + const Comp = () => { + return (() => { + const _el$ = $tmpl(); + $$className(_el$, { + red: true, + }); + return _el$; + })(); + }; + render(() => $$runComponent(Comp, {}), container); +``` + +### Array class +通过`$$className`设置class +```jsx + /** + * 源码: + * const Comp = () => { + * return
Count value is 0.
; + * } + */ + // 编译后: + const $tmpl = /*#__PURE__*/ $$template('
Count value is 0.'); + const Comp = () => { + return (() => { + const _el$ = $tmpl(); + $$className(_el$, ['red', 'green']); + return _el$; + })(); + }; + render(() => $$runComponent(Comp, {}), container); +``` + +## Attribute +使用`$$setAttribute`设置属性 +```jsx +function App() { + return ( +

parallel

+ ); +} + +// 编译后: +const $tmpl = /*#__PURE__*/ $$template('
Count value is 0.'); +const Comp = () => { + return (() => { + const $div = $tmpl(); + $$setAttribute($div, 'id', 'test'); + return $div; + })(); +}; +``` +使用响应式时,包裹在`$$effect`中 +```jsx + //源码: +function App() { + const id = reactive('el'); + return ( +

parallel

+ ); +} + +// 编译后: +const $tmpl = /*#__PURE__*/ $$template('
Count value is 0.'); +const id = reactive('el'); +const Comp = () => { + return (() => { + const _el$ = $tmpl(); + $$effect(() => $$setAttribute(_el$, 'id', id.get())); + return _el$; + })(); +}; +``` + +## Fragment +Fragment编译为数组,并且每个元素都是一个IIFE,返回对应的dom。 +```jsx +/** + * 源码: + * const CountingComponent = () => { + * const [count, setCount] = createSignal(0); + * const add = () => { + * setCount((c) => c + 1); + * } + * return <> + *
Count value is {count()}.
+ *
+ * ; + * }; + */ + + // 编译后: +const $tmpl = /*#__PURE__*/ $$template('
Count value is .'), + $tmpl_2 = /*#__PURE__*/ $$template('
+// to +$$on(_el$6, 'click', handleDeleteClick, true); + + +// to +$$on(_el$6, 'change', handleDeleteClick, true); +``` + +## Dom Ref +```jsx +function App() { + let myDiv; + return
My Element
; +} + +// to + +function App() { + let myDiv; + return (() => { + const $div = _tmpl$(); + const $ref = myDiv; + typeof $ref === "function" ? $$bindRef($ref, _el$) : myDiv = $div; + return _el$; + })(); +} +``` +## Styling +### Inline Style +```jsx +/** + * 源码: + * const CountingComponent = () => { + * return
Count value is 0.
; + * }; + * + * render(() => , container); + */ + + // 编译后: +const $tmpl = /*#__PURE__*/ $$template('
Count value is 0.'); +const Comp = () => { + return $tmpl(); +}; +render(() => $$runComponent(Comp, {}), container); +``` +### Object Style +每个style属性通过`$$setProperty`设置。 +动态style通过`$$effect`包裹。 +```jsx + +/** + * 源码: + * const Comp = () => { + * const color = reactive('red'); + * return
Count value is 0.
; + * } + */ + + // 编译后: +const $tmpl = /*#__PURE__*/ $$template('
Count value is 0.'); +const Comp = () => { + return (() => { + const _el$ = $tmpl(); + $$effect(() => + color.get() != null ? _el$.style.setProperty('color', color.get()) : _el$.style.removeProperty('color') + ); + _el$.style.setProperty('display', 'flex'); + return _el$; + })(); +}; + +``` +## Loop + +```jsx + + {todo => <>} + + +// to +$$runComponent(For, { + get each() { + return state.todoList; + }, + children: todo => [ + $$runComponent(Todo, { + todo: todo, + }), + $$runComponent(Todo, { + todo: todo, + }), + ], +}) + +``` + +## Conditional +编译为`Cond`组件,`Cond`组件接收`branches`属性。 +`branches`属性为一个数组,数组中的每个元素都是一个数组,数组的第一个元素是条件,第二个元素是返回的dom。 +```ts +// It's boolean when the condition is static of default branch +// Otherwise it's a function that return boolean +type CondExpression = boolean | (() => boolean); +// When branch only include static JSXElement the branch can be a JSXElement +// Otherwise, the branch should be a function that return JSXElement +type Branch = JSXElement | (() => JSXElement); + +export interface CondProps { + // Array of tuples, first item is the condition, second is the branch to render + branches: [CondExpression, Branch][]; +} +``` +```jsx +/** + * 源码: + * const fn = vi.fn(); + * function App() { + * const x = reactive(7); + * + * return ( + *
+ *

if

+ * 10}> + *

{x.get()} is greater than 10

+ *
+ * x.get()}> + *

{x.get()} is less than 5

+ *
+ * + *

{x.get()} is between 5 and 10

+ *
+ *
+ * ); + * } + * render(() => , container); + */ + + // 编译后: + +const _tmpl$ = /*#__PURE__*/ _$template(`

is greater than 10`), + _tmpl$2 = /*#__PURE__*/ _$template(`

is less than 5`), + _tmpl$3 = /*#__PURE__*/ _$template(`

xxx`), + _tmpl$4 = /*#__PURE__*/ _$template(`

is between 5 and 10`); +let change; + +function App() { + const x = reactive(7); + change = v => x.set(v); + return (() => { + const _el$ = _tmpl$3(), + _el$2 = _el$.firstChild; + _$insert( + _el$, + _$runComponent(Cond, { + get branches() { + return [ + [ + () => x.get() > 10, + () => { + const _el$3 = _tmpl$(), + _el$4 = _el$3.firstChild; + _$insert(_el$3, x, _el$4); + return _el$3; + }, + ], + [ + () => 5 > x.get(), + () => { + const _el$5 = _tmpl$2(), + _el$6 = _el$5.firstChild; + _$insert(_el$5, x, _el$6); + return _el$5; + }, + ], + [ + true, + () => { + const _el$7 = _tmpl$4(), + _el$8 = _el$7.firstChild; + _$insert(_el$7, x, _el$8); + return _el$7; + }, + ], + ]; + }, + }), + null + ); + + return _el$; + })(); +} + +render(() => _$runComponent(App, {}), container); +``` +## Early Return + +# **Component composition** + +## Props + +使用`.get()`的表达式作为props时,转为对象时通过getter包装。 +使用props时,通过props.(参数名)访问来保证在组件时读取props时能够保持响应。 + +> 好处: 1. 使用props时无需感知是否为响应式,无需额外判断 2. 使用统一,JSX和函数体JS都通过.get() 读值 +> 问题: 1. 不能解构 -> 通过2.0 API编译语法糖解决 + +```jsx +function App() { + const name = reactive("init") + return +} + +function Button({name}) { + cosnt + greeting = name + '!' + return

{greeting}

+} + +// 2.0 语法编译后 +function App() { + const name = reactive('init'); + return +} + +function Button(props) { + cosnt + greeting = computed(() => props.name + '!') + return

{greeting.get()}

+} +``` + +备选方案: +JSX中传递响应式时直接传递响应式变量,无需`.get()`。 +使用时从props取对应的响应式值进行使用。 + +> 好处: 可以解构,组件显式处理props中的响应式 +> 问题: 1. 使用props时需额外判断props是否为响应式,2.0API 无法向下编译 + +2. JSX使用响应式不要get,JS使用要get + +```jsx +function App() { + const name = reactive('init'); + return +} + +$$runComponent(Button, { + class: name +} +}) + +function Button({name}) { + cosnt + greeting = computed(() => (isReactiveObj(props.name) ? props.name.get() : props.name) + '!') + return

{reeting.get()}

+} +``` + +## Slot +通过children属性传递slot。IIFE包裹slot,返回对应的dom。 +```jsx +/** + * 源码: + * const CountValue = (props) => { + * return
Title: {props.children}
; + * } + * + * const CountingComponent = () => { + * const [count, setCount] = createSignal(0); + * + * return
+ *

FOO

+ *
; + * }; + * + * render(() => , document.getElementById("app")); + */ + + // 编译后: +const _tmpl$ = /*#__PURE__*/ $$template(`
Title: `), + _tmpl$2 = /*#__PURE__*/ $$template(`

Your count is .`), + _tmpl$3 = /*#__PURE__*/ $$template(`
`); +const CountValue = props => { + return (() => { + const _el$ = _tmpl$(), + _el$2 = _el$.firstChild; + $$insert(_el$, () => props.children, null); + return _el$; + })(); +}; +const CountingComponent = () => { + const count = reactive(0); + const add = () => { + count.set(c => c + 1); + }; + return (() => { + const _el$3 = _tmpl$3(), + _el$8 = _el$3.firstChild, + _el$9 = _el$8.firstChild; + $$insert( + _el$3, + $$runComponent(CountValue, { + get children() { + const _el$4 = _tmpl$2(), + _el$5 = _el$4.firstChild, + _el$7 = _el$5.nextSibling, + _el$6 = _el$7.nextSibling; + $$insert(_el$4, count, _el$7); + return _el$4; + }, + }), + _el$8, + ); + return _el$3; + })(); +}; +``` +## Context diff --git a/packages/inula-novdom/src/core.ts b/packages/inula-novdom/src/core.ts index 9a6b53e3..5bbaf3d5 100644 --- a/packages/inula-novdom/src/core.ts +++ b/packages/inula-novdom/src/core.ts @@ -14,9 +14,12 @@ */ import { insert } from './dom'; -import { untrack } from 'inula-reactive'; +import { RNode, untrack, setScheduler } from 'inula-reactive'; import { readContext } from './components/Env'; +// enable the scheduler +setScheduler(); + type ComponentConstructor = (props: T, context: any) => any; type CodeFunction = () => any; @@ -58,3 +61,11 @@ export function render(codeFn: CodeFunction, element: HTMLElement): () => void { element.textContent = ''; }; } + +/** + * onMount is a lifecycle hook that runs after the component is mounted + * @param fn mount function + */ +export function onMount(fn: () => void): void { + new RNode(() => untrack(fn), { isEffect: true, lazy: true }); +} diff --git a/packages/inula-novdom/tests/env.test.tsx b/packages/inula-novdom/tests/env.test.tsx index 74e7ac15..a6cb0342 100644 --- a/packages/inula-novdom/tests/env.test.tsx +++ b/packages/inula-novdom/tests/env.test.tsx @@ -27,7 +27,6 @@ describe('env', () => { it('should work.', ({container}) => { /** * 源码: - * const fn = vi.fn(); * function App() { * const x = reactive(7); * const theme = reactive('dark'); @@ -77,7 +76,6 @@ describe('env', () => { it('should work with nested env.', ({container}) => { /** * 源码: - * const fn = vi.fn(); * function App() { * const theme = reactive('dark'); * return ( @@ -145,7 +143,6 @@ describe('env', () => { it('should merged the parent env.', ({container}) => { /** * 源码: - * const fn = vi.fn(); * function App() { * const theme = reactive('dark'); * return ( @@ -272,7 +269,6 @@ describe('env', () => { it('should work with recursive env, like menu.', ({container}) => { /** * 源码: - * const fn = vi.fn(); * function App() { * return ( * diff --git a/packages/inula-novdom/tests/lifecycle.test.tsx b/packages/inula-novdom/tests/lifecycle.test.tsx new file mode 100644 index 00000000..29b942bf --- /dev/null +++ b/packages/inula-novdom/tests/lifecycle.test.tsx @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck For the compiled code. + +import { describe, expect } from 'vitest'; +import { domTest as it, nextTick } from './utils'; +import { template as $$template, insert as $$insert } from '../src/dom'; +import { runComponent as $$runComponent, render, onMount } from '../src/core'; +import { reactive } from 'inula-reactive'; + +describe('onMount', () => { + it('should work.', async ({ container, log }) => { + /** + * 源码: + * function App() { + * onMount(() => { + * log.add('App'); + * }); + * return ; + * } + * function Child() { + * onMount(() => { + * log.add('Child'); + * }); + * return ; + * } + * function GrandChild() { + * onMount(() => { + * log.add('GrandChild'); + * }); + * } + * render(() => , container); + */ + + // 编译后: + function App() { + onMount(() => { + log.add('App'); + }); + return $$runComponent(Child); + } + + function Child() { + onMount(() => { + log.add('Child'); + }); + return $$runComponent(GrandChild); + } + + function GrandChild() { + onMount(() => { + log.add('GrandChild'); + }); + } + + render(() => $$runComponent(App, {}), container); + await nextTick(); + expect(log.get()).toEqual(['App', 'Child', 'GrandChild']); + }); + + it('should support multiple onMounts.', async ({ container, log }) => { + /** + * 源码: + * function App() { + * onMount(() => { + * log.add('App1'); + * }); + * onMount(() => { + * log.add('App2'); + * }); + * return
; + * } + */ + // 编译后: + const _tmpl$ = /*#__PURE__*/ $$template(`
`); + + function App() { + onMount(() => { + log.add('App1'); + }); + onMount(() => { + log.add('App2'); + }); + return _tmpl$(); + } + render(() => $$runComponent(App, {}), container); + + await nextTick(); + expect(log.get()).toEqual(['App1', 'App2']); + }); + + it('should work in hooks.', async ({ container, log }) => { + const fakeFetch = () => + new Promise(resolve => { + resolve('fake data'); + }); + + function useFetch() { + const data = reactive(null); + onMount(async () => { + const resp = await fakeFetch(); + data.set(resp); + }); + return data; + } + + /** + * 源码: + * function App() { + * const data = useFetch(); + * return
{data.get()}
; + * } + */ + // 编译后: + const _tmpl$ = /*#__PURE__*/ $$template(`
`); + + function App() { + const data = useFetch(); + return (() => { + const _el$ = _tmpl$(); + $$insert(_el$, () => data.get()); + return _el$; + })(); + } + + render(() => $$runComponent(App, {}), container); + await nextTick(); + expect(container.innerHTML).toBe('
fake data
'); + }); +}); diff --git a/packages/inula-novdom/tests/ref.test.ts b/packages/inula-novdom/tests/ref.test.ts index 1605c4ce..4f650874 100644 --- a/packages/inula-novdom/tests/ref.test.ts +++ b/packages/inula-novdom/tests/ref.test.ts @@ -15,13 +15,13 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck For the compiled code. -import { describe, expect } from 'vitest'; -import { domTest as it } from './utils'; +import { describe, expect, vi } from 'vitest'; +import { domTest as it, nextTick } from './utils'; import { template as $$template, bindRef as $$bindRef } from '../src/dom'; -import { runComponent as $$runComponent, render } from '../src/core'; +import { runComponent as $$runComponent, render, onMount } from '../src/core'; describe('ref', () => { - it('should reference to dom.', ({ container }) => { + it('should reference to dom.', ({container}) => { /* * 源码: * function App(props) { @@ -45,7 +45,7 @@ describe('ref', () => { expect(ref).toBe(container.firstChild); }); - it('should reference to component.', ({ container }) => { + it('should reference to component.', ({container}) => { /** * 源码: * // App.tsx @@ -88,4 +88,37 @@ describe('ref', () => { render(() => $$runComponent(App, {}), container); expect(canvas).toBe(container.firstChild); }); + + it('should be accessible in onMount.', async ({container}) => { + /** + * 源码: + * function App() { + * let ref; + * onMount(() => { + * console.log(ref); + * }); + * return ; + * } + */ + const $tmpl$ = $$template(''); + const fn = vi.fn(); + + function App() { + let ref: Node; + onMount(() => { + fn(ref); + }); + return (() => { + const $div = $tmpl$(); + const $ref = ref; + typeof $ref === 'function' ? $$bindRef($ref, $div) : (ref = $div); + return $div; + })(); + } + + render(() => $$runComponent(App, {}), container); + + await nextTick(); + expect(fn).toBeCalledWith(container.firstChild); + }); }); diff --git a/packages/inula-novdom/tests/utils.ts b/packages/inula-novdom/tests/utils.ts index 3b140be4..aebc07f1 100644 --- a/packages/inula-novdom/tests/utils.ts +++ b/packages/inula-novdom/tests/utils.ts @@ -16,7 +16,26 @@ import { test } from 'vitest'; interface DomTestContext { container: HTMLDivElement; + log: Log; } + +class Log { + private messages: string[] = []; + + add(message: string) { + this.messages.push(message); + } + + get() { + return this.messages; + } + + empty() { + this.messages = []; + } +} + +const log = new Log(); // Define a new test type that extends the default test type and adds the container fixture. export const domTest = test.extend({ container: async ({ task }, use) => { @@ -25,4 +44,14 @@ export const domTest = test.extend({ await use(container); container.remove(); }, + log: async ({ task }, use) => { + await use(log); + log.empty(); + }, }); + +export function nextTick(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} diff --git a/packages/inula-reactive/src/RNodeCreator.ts b/packages/inula-reactive/src/RNodeCreator.ts index 3ba29a5c..b93badf5 100644 --- a/packages/inula-reactive/src/RNodeCreator.ts +++ b/packages/inula-reactive/src/RNodeCreator.ts @@ -15,7 +15,7 @@ import { isPrimitive } from './Utils'; import { RNode } from './RNode'; -import { Fn, NoArgFn, NonFunctionType, Signal } from './Types'; +import { NoArgFn, NonFunctionType, Signal } from './Types'; import { DeepReactive, RProxyNode } from './RProxyNode'; import { getRNodeVal } from './RNodeAccessor'; @@ -44,12 +44,10 @@ export function createComputed(fn: T, deep = true) { } export function createWatch(fn: T) { - const rNode = new RNode(fn, { + return new RNode(fn, { isEffect: true, lazy: false, }); - - return rNode; } export function getOrCreateChildProxy(value: unknown, parent: RProxyNode, key: string | symbol) {