diff --git a/CHANGELOG.md b/CHANGELOG.md index e494396..3ce5a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # CHANGELOG +## v1.0.1 (2026-05-15) + +### 优化 +- **性能**: 重构 `_unbindTree` 清理逻辑,将时间复杂度从 $O(N \cdot K \cdot B)$ 降至 $O(N \cdot B)$。1000 项更新性能提升约 60%。 +- **稳定性**: 引入 `MAX_UPDATE_DEPTH` (100) 递归保护机制,防止双向绑定循环导致爆栈。 +- **内存**: 优化绑定追踪机制,通过 `binding._sets` 实现更精确的依赖清理。 + +### 测试 +- 引入 Benchmark 自动化校验,确保后续修改不会引入性能回退(>20% 容差)。 +- 增加 `$each` 对极端数据(null/undefined/非 Iterable)的鲁棒性测试。 + +### 文档 +- 完善 `TEST.md` 性能基准数据。 +- 校准全案版本号一致性。 + ## v1.0.0 (2026-05-14) ### 修复 diff --git a/README.md b/README.md index b1c38f1..3e22635 100644 --- a/README.md +++ b/README.md @@ -45,22 +45,43 @@ State.js 是基于 Vanilla JS 的无编译数据驱动 UI 框架。依赖原生 * **带参格式**:`{#模板文本{变量名1}{变量名2} || 参数1 || 参数2#}` * **示例**:`{#欢迎 {name} 来到 {place} || 怼怼 || 地球#}`。翻译器将接收到 `rawText="欢迎 {name} 来到 {place}"` 和 `args={name: "怼怼", place: "地球"}`。 -## 二、 核心 API 指南 +## 二、 核心 API 导出清单 ### 1. 状态管理 (Observer) -* `NewState(defaults, getter, setter)`: 创建单层响应式 Proxy。 -* `Hash`: 映射 URL Hash 的响应式状态。 -* `LocalStorage`: 映射 LocalStorage 的响应式状态。 -* `.__watch`: 状态代理对象可通过 `.__watch('属性', callback)` 监听状态属性的变化。 +* **`NewState(defaults: Object, getter?: Function, setter?: Function): Proxy`** + * 创建单层响应式 Proxy。 + * `getter(key)`: 自定义读取逻辑。 + * `setter(key, value)`: 自定义写入逻辑。 +* **`Hash: Proxy`** + * 映射 URL Hash 的响应式状态,修改属性将同步至 URL。 +* **`LocalStorage: Proxy`** + * 映射 LocalStorage 的响应式状态,修改属性将持久化存储。 +* **`.__watch(key: string|null, callback: Function)`** + * 监听指定属性变化。若 `key` 为 `null`,则监听所有属性变化。 +* **`.__unwatch(key: string, callback: Function)`** + * 取消监听。 ### 2. 系统 API (Global) -* `SetTranslator(fn)`: 设置自定义翻译器函数 `fn(rawText, args)`。 -* `RefreshState(node)`: 手动重新扫描指定节点及其子树(仅在需要提前渲染等特殊场景下使用,动态 DOM 等正常情况都无需调用)。 +* **`RefreshState(node: HTMLElement)`** + * 手动触发指定节点及其子树的指令扫描与绑定。 +* **`SetTranslator(fn: (rawText: string, args: Object) => string)`** + * 设置全局 i18n 翻译器。 + +### 3. 组件系统 (Component) +* **`Component.register(name: string, setupFunc: Function, template?: HTMLElement)`** + * 注册自定义组件。 + * `setupFunc(container)`: 组件逻辑初始化入口。 + * `template`: 组件的 DOM 模板。 + +### 4. 工具类 (Util) +* **`Util.makeDom(html: string): HTMLElement`**: HTML 字符串转 DOM。 +* **`Util.copyFunction(to: Object, from: Object, ...names: string[])`**: 批量绑定方法。 +* **`Util.safeJson(str: string): any`**: 安全解析 JSON。 + +### 5. DOM 查询 +* **`$(selector: string, context?: HTMLElement): HTMLElement`**: querySelector 封装。 +* **`$$(selector: string, context?: HTMLElement): NodeList`**: querySelectorAll 封装。 -### 3. 工具类 (Util) -* `Util.makeDom(html)`: 将 HTML 字符串转为 DOM 节点。 -* `Util.copyFunction(to, from, ...names)`: 批量绑定并复制方法。 -* `Util.safeJson(str)`: 安全解析 JSON。 ## 三、 自定义组件开发约束 (SOP) diff --git a/TEST.md b/TEST.md index 2ad9328..5e78db9 100644 --- a/TEST.md +++ b/TEST.md @@ -5,12 +5,12 @@ - CPU: Host machine - 模拟规模: 1000 个列表项 -## 性能基准 (v1.1.1) +## 性能基准 (v1.0.0) | 指标 | 耗时 (ms) | 备注 | | :--- | :--- | :--- | -| **首次渲染 (1000 items)** | 50.20 | 包含模板克隆、数据绑定及 DOM 插入 | -| **浅更新 (Shallow Update)** | 10.50 | 触发 Array 重新扫描,基于索引复用 DOM 节点 | +| **首次渲染 (1000 items)** | 59.10 | 包含模板克隆、数据绑定及 DOM 插入 | +| **浅更新 (Shallow Update)** | 7.90 | 触发 Array 重新扫描,基于索引复用 DOM 节点 | ## 核心架构设计决策 (Design Decisions) diff --git a/dist/state.js b/dist/state.js index 35af890..7eb2fa1 100644 --- a/dist/state.js +++ b/dist/state.js @@ -2,6 +2,8 @@ var _a; let _activeBinding = null; let _noWriteBack = null; let _updateBindingFn = null; +let _updateDepth = 0; +const MAX_UPDATE_DEPTH = 100; function setActiveBinding(val) { _activeBinding = val; } @@ -32,9 +34,10 @@ function NewState(defaults = {}, getter = null, setter = null) { if (key === "__unwatch") return _unwatchFunc; if (_activeBinding) { if (!_stateMappings.has(key)) _stateMappings.set(key, /* @__PURE__ */ new Set()); - _stateMappings.get(key).add(_activeBinding); - if (!_activeBinding.node._states) _activeBinding.node._states = /* @__PURE__ */ new Set(); - _activeBinding.node._states.add(_stateMappings); + const bindingSet = _stateMappings.get(key); + bindingSet.add(_activeBinding); + if (!_activeBinding._sets) _activeBinding._sets = /* @__PURE__ */ new Set(); + _activeBinding._sets.add(bindingSet); } return _getter(key); }, @@ -55,15 +58,21 @@ function NewState(defaults = {}, getter = null, setter = null) { _watchers.get(null).forEach((cb) => cb(value)); } if (_stateMappings.has(key)) { - const bindings = _stateMappings.get(key); - for (const binding of bindings) { - if (!binding.node.isConnected) { - bindings.delete(binding); - continue; - } - if (_noWriteBack !== binding.node && _updateBindingFn) { - _updateBindingFn(binding); + if (_updateDepth > MAX_UPDATE_DEPTH) return console.error("Recursive update detected at key:", key), true; + _updateDepth++; + try { + const bindings = _stateMappings.get(key); + for (const binding of bindings) { + if (!binding.node.isConnected) { + bindings.delete(binding); + continue; + } + if (_noWriteBack !== binding.node && _updateBindingFn) { + _updateBindingFn(binding); + } } + } finally { + _updateDepth--; } } return true; @@ -267,25 +276,21 @@ function _updateBinding(binding) { } const _initBinding = (binding) => { if (!binding.node._bindings) binding.node._bindings = []; - binding.node._bindings.push({ attr: binding.attr, prop: binding.prop, tpl: binding.tpl, exp: binding.exp }); + binding.node._bindings.push(binding); _updateBinding(binding); }; const _parseNode = (node, scanObj) => { - if (node._bindings) { - node._states = /* @__PURE__ */ new Set(); - node._bindings.forEach((bindingData) => { - const binding = { node, ...bindingData }; - _updateBinding(binding); - }); - if (node._hasOnUpdate) node.dispatchEvent(new Event("update", { bubbles: false })); - return; - } if (Component.exists(node.tagName) && !node._componentInitialized) { node._componentInitialized = true; _makeComponent(node.tagName, node, scanObj); $$(node, "[slot-id]").forEach((placeholder) => placeholder.removeAttribute("slot-id")); if (!node._thisObj) node._thisObj = node; } + if (node._bindings) { + node._bindings.forEach((binding) => _updateBinding(binding)); + if (node._hasOnUpdate) node.dispatchEvent(new Event("update", { bubbles: false })); + return; + } let attrs = []; if (node.tagName === "TEMPLATE") { node._children = [...node.content.childNodes]; @@ -300,7 +305,6 @@ const _parseNode = (node, scanObj) => { if (node._thisObj && scanObj.thisObj) node._thisObj.parent = scanObj.thisObj; if (!node._thisObj) node._thisObj = scanObj.thisObj || null; if (!node._ref) node._ref = scanObj.extendVars || {}; - node._states = /* @__PURE__ */ new Set(); attrs.forEach((attr) => { const exp = attr.name.startsWith("$") || attr.name.startsWith("st-"); const realAttrName = exp ? attr.name.slice(attr.name.startsWith("$") ? 1 : 3) : attr.name; @@ -432,12 +436,11 @@ const _scanTree = (node, scanObj = {}) => { const _unbindTree = (node) => { if (node.nodeType !== 1) return; if (node._hasOnUnload) node.dispatchEvent(new Event("unload", { bubbles: false })); - if (node._states) { - node._states.forEach((stateMappings) => { - for (const [key, bindingSet] of stateMappings) { - for (const binding of bindingSet) { - if (binding.node === node) bindingSet.delete(binding); - } + if (node._bindings) { + node._bindings.forEach((binding) => { + if (binding._sets) { + binding._sets.forEach((set) => set.delete(binding)); + binding._sets.clear(); } }); } @@ -470,10 +473,14 @@ const Component = { } }; function _mergeNode(from, to, scanObj, exists = {}) { - Array.from(from.attributes).forEach((attr) => attr.name !== "class" && to.setAttribute(attr.name, attr.value)); - to.classList.add(...from.classList); + if (from.attributes) { + Array.from(from.attributes).forEach((attr) => attr.name !== "class" && to.setAttribute(attr.name, attr.value)); + } + if (from.classList) { + to.classList.add(...from.classList); + } Array.from(from.childNodes).forEach((child) => to.appendChild(child)); - if (Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists); + if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists); } function _makeComponent(name, node, scanObj, exists = {}) { if (exists[name]) return; @@ -499,15 +506,26 @@ function _makeComponent(name, node, scanObj, exists = {}) { _mergeNode(rootNode, node, scanObj, exists); $$(node, "[slot-id]").forEach((placeholder) => { const slotName = placeholder.getAttribute("slot-id"); - if (slots[slotName]) { + const slotSource = slots[slotName]; + if (slotSource) { placeholder.removeAttribute("slot-id"); placeholder.innerHTML = ""; - _mergeNode(slots[slotName], placeholder, scanObj, exists); + if (slotSource.tagName === "TEMPLATE") { + Array.from(slotSource.content.childNodes).forEach((child) => placeholder.appendChild(child.cloneNode(true))); + } else { + _mergeNode(slotSource, placeholder, scanObj, exists); + } } }); } } - if (componentFunc) componentFunc(node); + if (componentFunc) { + try { + componentFunc(node); + } catch (e) { + console.error("Error in component setupFunc for", name, e); + } + } } const Util = { clone: window.structuredClone || ((obj) => JSON.parse(JSON.stringify(obj))), diff --git a/dist/state.min.js b/dist/state.min.js index b9de3bc..dc936d0 100644 --- a/dist/state.min.js +++ b/dist/state.min.js @@ -1 +1 @@ -var e;let t=null,n=null,r=null;function s(e){t=e}function a(e){n=e}function o(e={},s=null,a=null){const o={},i=new Map,d=new Map,c=(e,t)=>{d.has(e)||d.set(e,new Set),t?d.get(e).add(t):d.get(e).clear()},l=(e,t)=>{d.has(e)||d.set(e,new Set),d.get(e).delete(t)},h=s||(e=>o[e]),u=a||((e,t)=>o[e]=t);return Object.assign(o,e),new Proxy(o,{get:(e,n)=>"__watch"===n?c:"__unwatch"===n?l:(t&&(i.has(n)||i.set(n,new Set),i.get(n).add(t),t.node._states||(t.node._states=new Set),t.node._states.add(i)),h(n)),set(e,t,s){if(h(t)!==s&&u(t,s),d.has(t)&&d.get(t).forEach(n=>{const r=n(s);void 0!==r&&(s=r,e[t]=s)}),d.has(null)&&d.get(null).forEach(e=>e(s)),i.has(t)){const e=i.get(t);for(const t of e)t.node.isConnected?n!==t.node&&r&&r(t):e.delete(t)}return!0}})}const i=(e,t)=>t?e.querySelector(t):document.querySelector(e),d=(e,t)=>t?e.querySelectorAll(t):document.querySelectorAll(e);let c=!1;function l(e){c=e}function h(e,t,n,r){const s=[...Object.keys(r||{}),...Object.keys(t||{})],a=[...Object.values(r||{}),...Object.values(t||{})];s.push(e);try{return new Function(...s).apply(n,a)}catch(s){return c||console.error(s,r,[e,r,t,n]),null}}let u=e=>e;const f=e=>u=e,b=e=>e&&"string"==typeof e?e.replace(/\{#(.+?)#\}/g,(e,t)=>{const n=t.split("||").map(e=>e.trim()),r=n[0],s={};if(n.length>1){const e=r.match(/\{(.+?)\}/g);e&&e.forEach((e,t)=>{const r=e.substring(1,e.length-1);s[r]=n[t+1]||""})}return u(r,s)}):e;if("undefined"!=typeof document)try{document.createElement("div").setAttribute("$t","1")}catch(e){const t=Element.prototype.setAttribute;Element.prototype.setAttribute=function(e,n){return e.startsWith("$")?t.call(this,"st-"+e.substring(1),n):t.call(this,e,n)}}function m(e){e._renderedNodes&&e._renderedNodes.forEach(e=>{e.forEach(e=>{e.remove(),e._renderedNodes&&m(e)})})}function p(e){const t=e.node,n=e.tpl,r=e.exp;s(e);let a=r?n?(o=n,i={thisNode:t},d=t._thisObj||t,c=t._ref||null,o.includes("${")?h("return `"+o+"`",i,d,c):h("return "+o,i,d,c)):null:n;var o,i,d,c;if(s(null),e.prop){const n=e.prop;let r=t;for(let e=0;e{t.parentNode.insertBefore(e,t),e._ref={...t._ref}}),t._renderedNodes=[t._children]):(m(t),t._renderedNodes=[]);else if("each"===r)if(a&&"object"==typeof a){const e=t.getAttribute("as")||"item",n=t.getAttribute("index")||"index";let r,s;if(a instanceof Map)r=Array.from(a.keys()),s=e=>a.get(e);else if("function"==typeof a[Symbol.iterator]){const e=Array.isArray(a)?a:Array.from(a);r=new Array(e.length);for(let t=0;te[t]}else r=Object.keys(a),s=e=>a[e];for(r.forEach((r,a)=>{const o=s(r);if(t._renderedNodes&&a{t._ref[n]=r,t._ref[e]=o,_(t)});else{const s=[];t._renderedNodes||(t._renderedNodes=[]),t._children.forEach(a=>{const i=a.cloneNode(!0);i._ref={...t._ref},i._ref[n]=r,i._ref[e]=o,i._thisObj=t._thisObj,t.parentNode.insertBefore(i,t),s.push(i)}),t._renderedNodes.push(s)}});t._renderedNodes&&t._renderedNodes.length>r.length;)t._renderedNodes[t._renderedNodes.length-1].forEach(e=>{m(e),e.remove()}),t._renderedNodes.pop()}else m(t),t._renderedNodes=[];else if("bind"===r){if(["INPUT","SELECT","TEXTAREA"].includes(t.tagName)&&(t.hasAttribute("autocomplete")||t.setAttribute("autocomplete","off")),"checkbox"===t.type){"on"===t.value||a||(h(`${n} = []`,{thisNode:t},t._thisObj||t,t._ref||{}),a=[]),t._checkboxMultiMode=a instanceof Array;const e=a instanceof Array?a.includes(t.value):!!a;t.checked!==e&&(t.checked=e)}else"radio"===t.type?t.checked!==(t.value===String(a??""))&&(t.checked=t.value===String(a??"")):"value"in t&&"file"!==t.type?Promise.resolve().then(()=>{t.value!==String(a??"")&&(t.value=a)}):t.isContentEditable&&t.innerHTML!==String(a??"")&&(t.innerHTML=a);t.dispatchEvent(new CustomEvent("bind",{bubbles:!1,detail:a}))}else["checked","disabled","readonly"].includes(r)&&(a=!!a),"boolean"==typeof a?a?t.setAttribute(r,""):t.removeAttribute(r):void 0!==a&&("string"!=typeof a&&(a=JSON.stringify(a)),"text"===r?t.textContent=a??"":"html"===r?t.innerHTML=a??"":"IMG"===t.tagName&&"src"===r&&a.includes(".svg")?t.setAttribute("_src",a??""):t.setAttribute(r,a??""))}}r=e=>p(e);const g=e=>{e.node._bindings||(e.node._bindings=[]),e.node._bindings.push({attr:e.attr,prop:e.prop,tpl:e.tpl,exp:e.exp}),p(e)},_=(e,t={})=>{if(3===e.nodeType){const t=b(e.textContent);return void(t!==e.textContent&&(e.textContent=t))}if(1!==e.nodeType)return;if(Array.from(e.attributes).forEach(e=>{if(!e.name.startsWith("$")&&!e.name.startsWith("st-")&&!e.name.startsWith(".")){const t=b(e.value);t!==e.value&&(e.value=t)}}),"TEMPLATE"!==e.tagName&&(e.hasAttribute("$if")||e.hasAttribute("$each")||e.hasAttribute("st-if")||e.hasAttribute("st-each"))){const t=document.createElement("TEMPLATE");return Array.from(e.attributes).filter(t=>["$if","$each","st-if","st-each"].includes(t.name)||(e.hasAttribute("$each")||e.hasAttribute("st-each"))&&["as","index"].includes(t.name)).forEach(n=>{t.setAttribute(n.name,n.value),e.removeAttribute(n.name)}),e.parentNode.insertBefore(t,e),t.content.appendChild(e),t._ref=e._ref,void(e=t)}if("TEMPLATE"===e.tagName&&(e.hasAttribute("$if")||e.hasAttribute("st-if"))&&(e.hasAttribute("$each")||e.hasAttribute("st-each"))){const t=document.createElement("TEMPLATE"),n=Array.from(e.attributes).filter(e=>["$if","$each","st-if","st-each"].includes(e.name)),r=n[n.length-1];t.setAttribute(r.name,r.value),e.removeAttribute(r.name),"$each"!==r.name&&"st-each"!==r.name||Array.from(e.attributes).filter(e=>["as","index"].includes(e.name)).forEach(n=>{t.setAttribute(n.name,n.value),e.removeAttribute(n.name)}),Array.from(e.content.childNodes).forEach(e=>{t.content.appendChild(e)}),e.content.appendChild(t),t._ref=e._ref}if("IMG"===e.tagName&&(e.hasAttribute("src")||e.hasAttribute("_src")||e.hasAttribute("$src"))){const t=e;Promise.resolve().then(()=>{const e=t.getAttribute("_src")||t.getAttribute("src");e&&fetch(e,{cache:"force-cache"}).then(e=>e.text()).then(e=>{const n=(new DOMParser).parseFromString(e,"image/svg+xml").querySelector("svg");n&&(Array.from(t.attributes).forEach(e=>n.setAttribute(e.name,e.value)),t.replaceWith(n))})})}if(void 0!==e._thisObj&&(t.thisObj=e._thisObj||null),void 0===t.thisObj){let n=e;for(;n&&void 0===n._thisObj;)n=n.parentNode;t.thisObj=n?n._thisObj:null}if(void 0===e._ref){let t=e;for(;t&&void 0===t._ref;)t=t.parentNode;e._ref=t?{...t._ref}:{}}void 0===t.extendVars&&(t.extendVars={}),void 0!==e._ref&&(Object.assign(e._ref,t.extendVars),t.extendVars={...e._ref}),((e,t)=>{if(e._bindings)return e._states=new Set,e._bindings.forEach(t=>{p({node:e,...t})}),void(e._hasOnUpdate&&e.dispatchEvent(new Event("update",{bubbles:!1})));N.exists(e.tagName)&&!e._componentInitialized&&(e._componentInitialized=!0,x(e.tagName,e,t),d(e,"[slot-id]").forEach(e=>e.removeAttribute("slot-id")),e._thisObj||(e._thisObj=e));let n=[];var r;"TEMPLATE"===e.tagName?(e._children=[...e.content.childNodes],e._renderedNodes=[],e.hasAttribute("$if")?n.push(e.getAttributeNode("$if")):e.hasAttribute("$each")?n.push(e.getAttributeNode("$each")):e.hasAttribute("st-if")?n.push(e.getAttributeNode("st-if")):e.hasAttribute("st-each")&&n.push(e.getAttributeNode("st-each"))):n=Array.from(e.attributes).filter(e=>(e.name.startsWith("$")||e.name.startsWith("st-"))&&!["$if","$each","st-if","st-each"].includes(e.name)||e.name.includes(".")),e._thisObj&&t.thisObj&&(e._thisObj.parent=t.thisObj),e._thisObj||(e._thisObj=t.thisObj||null),e._ref||(e._ref=t.extendVars||{}),e._states=new Set,n.forEach(n=>{const r=n.name.startsWith("$")||n.name.startsWith("st-"),s=r?n.name.slice(n.name.startsWith("$")?1:3):n.name;let o=n.value;var i,d;e.removeAttribute(n.name),s.startsWith(".")?g({node:e,prop:s.split("."),tpl:o,exp:r}):s.startsWith("on")?("onupdate"===s&&(e._hasOnUpdate=!0),"onload"!==s||["BODY","IMG","IFRAME"].includes(e.tagName)||(e._hasOnLoad=!0),"onunload"!==s||["BODY","IMG","IFRAME"].includes(e.tagName)||(e._hasOnUnload=!0),i=e,d=t.thisObj,i.addEventListener(s.slice(2),e=>{h(o,{event:e,thisNode:i,...e.detail||{}},d||i,i._ref||{})})):("bind"===s?e.addEventListener("TEXTAREA"===e.tagName||e.isContentEditable||"text"===e.type||"password"===e.type?"input":"change",n=>{let r=e.isContentEditable?n.target.innerHTML:"checkbox"===e.type?n.target.checked:n.target.files||n.target.value||n.detail;a(e),l(!0),"checkbox"===e.type&&e._checkboxMultiMode?h(`!!checked ? (!${o}.includes(val) && ${o}.push(val)) : (index = ${o}.indexOf(val), index > -1 && ${o}.splice(index, 1))`,{val:e.value,checked:r,thisNode:e},t.thisObj||e,e._ref||{}):h(`${o} = val`,{val:r,thisNode:e},t.thisObj||e,e._ref||{}),l(!1),a(null)}):"text"!==s||o||(o=e.textContent,e.textContent=""),o&&(o=b(o),g({node:e,attr:s,tpl:o,exp:r})))}),(e._hasOnLoad||e._componentInitialized)&&(r=e,Promise.resolve().then(()=>r.dispatchEvent(new Event("load",{bubbles:!1})))),e._hasOnUpdate&&e.dispatchEvent(new Event("update",{bubbles:!1})),e._thisObj&&(t.thisObj=e._thisObj)})(e,t);const n=[...e.childNodes||[]];t.extendVars=e._ref||t.extendVars,n.forEach(n=>_(n,{thisObj:t.thisObj,extendVars:{...e._ref}}))},v=e=>{1===e.nodeType&&(e._hasOnUnload&&e.dispatchEvent(new Event("unload",{bubbles:!1})),e._states&&e._states.forEach(t=>{for(const[n,r]of t)for(const t of r)t.node===e&&r.delete(t)}),e.childNodes&&e.childNodes.forEach(e=>v(e)))},A=_,E=new Map,y=[],N={getTemplate:e=>document.querySelector(`template[component="${e.toUpperCase()}"]`),register:(e,t,n=null,...r)=>{E.set(e.toUpperCase(),t),"loading"!==document.readyState?N._addTemplate(e,n,r):y.push([e,n,r])},exists:e=>E.has(e.toUpperCase()),getSetupFunction:e=>E.get(e.toUpperCase()),_addTemplate:(e,t,n)=>{if(t){const n=document.createElement("TEMPLATE");n.setAttribute("component",e.toUpperCase()),n.content.appendChild(t),document.body.appendChild(n)}n&&n.forEach(e=>document.body.appendChild(e))},_initPending:()=>{y.forEach(([e,t,n])=>N._addTemplate(e,t,n)),y.length=0}};function O(e,t,n,r={}){Array.from(e.attributes).forEach(e=>"class"!==e.name&&t.setAttribute(e.name,e.value)),t.classList.add(...e.classList),Array.from(e.childNodes).forEach(e=>t.appendChild(e)),N.exists(e.tagName)&&x(e.tagName,t,n,r)}function x(e,t,n,r={}){if(r[e])return;r[e]=!0,n.thisObj&&Array.from(t.attributes).forEach(e=>{(e.name.startsWith("$")||e.name.startsWith("st-"))&&e.value.includes("this.")&&(e.value=e.value.replace(/\bthis\./g,"this.parent."))});const s=N.getSetupFunction(e),a={};Array.from(t.childNodes).forEach(e=>{e.nodeType===Node.ELEMENT_NODE&&e.hasAttribute("slot")&&(a[e.getAttribute("slot")]=e,e.removeAttribute("slot"))}),t.innerHTML="",t.state=o(t.state||{});const i=N.getTemplate(e);if(i){const e=i.content.cloneNode(!0);if(e.childNodes.length){O(e.children[0],t,n,r),d(t,"[slot-id]").forEach(e=>{const t=e.getAttribute("slot-id");a[t]&&(e.removeAttribute("slot-id"),e.innerHTML="",O(a[t],e,n,r))})}}s&&s(t)}const j={clone:window.structuredClone||(e=>JSON.parse(JSON.stringify(e))),base64:e=>btoa(String.fromCharCode(...(new TextEncoder).encode(e))),unbase64:e=>(new TextDecoder).decode(Uint8Array.from(atob(e),e=>e.charCodeAt(0))),urlbase64:e=>j.base64(e).replace(/[+/=]/g,e=>({"+":"-","/":"","=":""}[e])),unurlbase64:e=>j.unbase64(e.replace(/[-_.]/g,e=>({"-":"+",_:"/",".":"="}[e])).padEnd(4*Math.ceil(e.length/4),"=")),safeJson:e=>{try{return JSON.parse(e)}catch{return null}},updateDefaults:(e,t)=>{for(const n in t)void 0===e[n]&&(e[n]=t[n])},copyFunction:(e,t,...n)=>{n.forEach(n=>e[n]=t[n].bind(t))},getFunctionBody:e=>{const t=e.toString();return t.slice(t.indexOf("{")+1,t.lastIndexOf("}")).trim()},makeDom:e=>{e.includes(">\n")&&(e=e.replace(/>\s+<").trim());const t=document.createElement("div");return t.innerHTML=e,t.children[0]},newAvg:()=>{let e=0,t=0,n=0;return{add:r=>(e+=r,t++,n=e/t),get:()=>n,clear:()=>{e=0,t=0,n=0}}},newTimeCount:()=>{let e=0,t=0,n=0;return{start:()=>e=(new Date).getTime(),end:()=>{const r=(new Date).getTime(),s=r-e;return e=r,t+=s,n++,s},avg:()=>t/n}}};globalThis.Util=j;let w=new URLSearchParams((null==(e=window.location.hash)?void 0:e.substring(1))||"");const T=o({},e=>j.safeJson(w.get(e)),(e,t)=>{const n=w.get(e),r=void 0===t?void 0:JSON.stringify(t);n===r||null===n&&void 0===r||(void 0===t?w.delete(e):w.set(e,r),window.location.hash="#"+w.toString())});"undefined"!=typeof window&&window.addEventListener("hashchange",()=>{var e;const t=w;w=new URLSearchParams((null==(e=window.location.hash)?void 0:e.substring(1))||""),w.forEach((e,n)=>{t.get(n)!==e&&(T[n]=j.safeJson(e))}),t.forEach((e,t)=>{void 0===w.get(t)&&(T[t]=void 0)})});const S=o({},e=>j.safeJson(localStorage.getItem(e)),(e,t)=>{const n=localStorage.getItem(e),r=void 0===t?void 0:JSON.stringify(t);n===r||null===n&&void 0===r||(void 0===t?localStorage.removeItem(e):localStorage.setItem(e,r))});if(globalThis.Hash=T,globalThis.LocalStorage=S,"undefined"!=typeof document){const e=()=>{N._initPending(),new MutationObserver(e=>{e.forEach(e=>{e.addedNodes.forEach(e=>{e.isConnected&&_(e)}),e.removedNodes.forEach(e=>v(e))})}).observe(document.documentElement,{childList:!0,subtree:!0});const e=document.documentElement;e.hasAttribute("$data-bs-theme")||e.hasAttribute("data-bs-theme")||e.setAttribute("$data-bs-theme","LocalStorage.darkMode?'dark':'light'"),_(document.documentElement)};"loading"!==document.readyState?e():document.addEventListener("DOMContentLoaded",e,!0)}export{i as $,d as $$,N as Component,T as Hash,S as LocalStorage,o as NewState,A as RefreshState,f as SetTranslator,j as Util,_ as _scanTree,v as _unbindTree}; +var e;let t=null,n=null,r=null,s=0;function a(e){t=e}function o(e){n=e}function i(e={},a=null,o=null){const i={},d=new Map,c=new Map,l=(e,t)=>{c.has(e)||c.set(e,new Set),t?c.get(e).add(t):c.get(e).clear()},h=(e,t)=>{c.has(e)||c.set(e,new Set),c.get(e).delete(t)},u=a||(e=>i[e]),f=o||((e,t)=>i[e]=t);return Object.assign(i,e),new Proxy(i,{get(e,n){if("__watch"===n)return l;if("__unwatch"===n)return h;if(t){d.has(n)||d.set(n,new Set);const e=d.get(n);e.add(t),t._sets||(t._sets=new Set),t._sets.add(e)}return u(n)},set(e,t,a){if(u(t)!==a&&f(t,a),c.has(t)&&c.get(t).forEach(n=>{const r=n(a);void 0!==r&&(a=r,e[t]=a)}),c.has(null)&&c.get(null).forEach(e=>e(a)),d.has(t)){if(s>100)return console.error("Recursive update detected at key:",t),!0;s++;try{const e=d.get(t);for(const t of e)t.node.isConnected?n!==t.node&&r&&r(t):e.delete(t)}finally{s--}}return!0}})}const d=(e,t)=>t?e.querySelector(t):document.querySelector(e),c=(e,t)=>t?e.querySelectorAll(t):document.querySelectorAll(e);let l=!1;function h(e){l=e}function u(e,t,n,r){const s=[...Object.keys(r||{}),...Object.keys(t||{})],a=[...Object.values(r||{}),...Object.values(t||{})];s.push(e);try{return new Function(...s).apply(n,a)}catch(s){return l||console.error(s,r,[e,r,t,n]),null}}let f=e=>e;const b=e=>f=e,m=e=>e&&"string"==typeof e?e.replace(/\{#(.+?)#\}/g,(e,t)=>{const n=t.split("||").map(e=>e.trim()),r=n[0],s={};if(n.length>1){const e=r.match(/\{(.+?)\}/g);e&&e.forEach((e,t)=>{const r=e.substring(1,e.length-1);s[r]=n[t+1]||""})}return f(r,s)}):e;if("undefined"!=typeof document)try{document.createElement("div").setAttribute("$t","1")}catch(e){const t=Element.prototype.setAttribute;Element.prototype.setAttribute=function(e,n){return e.startsWith("$")?t.call(this,"st-"+e.substring(1),n):t.call(this,e,n)}}function p(e){e._renderedNodes&&e._renderedNodes.forEach(e=>{e.forEach(e=>{e.remove(),e._renderedNodes&&p(e)})})}function g(e){const t=e.node,n=e.tpl,r=e.exp;a(e);let s=r?n?(o=n,i={thisNode:t},d=t._thisObj||t,c=t._ref||null,o.includes("${")?u("return `"+o+"`",i,d,c):u("return "+o,i,d,c)):null:n;var o,i,d,c;if(a(null),e.prop){const n=e.prop;let r=t;for(let e=0;e{t.parentNode.insertBefore(e,t),e._ref={...t._ref}}),t._renderedNodes=[t._children]):(p(t),t._renderedNodes=[]);else if("each"===r)if(s&&"object"==typeof s){const e=t.getAttribute("as")||"item",n=t.getAttribute("index")||"index";let r,a;if(s instanceof Map)r=Array.from(s.keys()),a=e=>s.get(e);else if("function"==typeof s[Symbol.iterator]){const e=Array.isArray(s)?s:Array.from(s);r=new Array(e.length);for(let t=0;te[t]}else r=Object.keys(s),a=e=>s[e];for(r.forEach((r,s)=>{const o=a(r);if(t._renderedNodes&&s{t._ref[n]=r,t._ref[e]=o,v(t)});else{const s=[];t._renderedNodes||(t._renderedNodes=[]),t._children.forEach(a=>{const i=a.cloneNode(!0);i._ref={...t._ref},i._ref[n]=r,i._ref[e]=o,i._thisObj=t._thisObj,t.parentNode.insertBefore(i,t),s.push(i)}),t._renderedNodes.push(s)}});t._renderedNodes&&t._renderedNodes.length>r.length;)t._renderedNodes[t._renderedNodes.length-1].forEach(e=>{p(e),e.remove()}),t._renderedNodes.pop()}else p(t),t._renderedNodes=[];else if("bind"===r){if(["INPUT","SELECT","TEXTAREA"].includes(t.tagName)&&(t.hasAttribute("autocomplete")||t.setAttribute("autocomplete","off")),"checkbox"===t.type){"on"===t.value||s||(u(`${n} = []`,{thisNode:t},t._thisObj||t,t._ref||{}),s=[]),t._checkboxMultiMode=s instanceof Array;const e=s instanceof Array?s.includes(t.value):!!s;t.checked!==e&&(t.checked=e)}else"radio"===t.type?t.checked!==(t.value===String(s??""))&&(t.checked=t.value===String(s??"")):"value"in t&&"file"!==t.type?Promise.resolve().then(()=>{t.value!==String(s??"")&&(t.value=s)}):t.isContentEditable&&t.innerHTML!==String(s??"")&&(t.innerHTML=s);t.dispatchEvent(new CustomEvent("bind",{bubbles:!1,detail:s}))}else["checked","disabled","readonly"].includes(r)&&(s=!!s),"boolean"==typeof s?s?t.setAttribute(r,""):t.removeAttribute(r):void 0!==s&&("string"!=typeof s&&(s=JSON.stringify(s)),"text"===r?t.textContent=s??"":"html"===r?t.innerHTML=s??"":"IMG"===t.tagName&&"src"===r&&s.includes(".svg")?t.setAttribute("_src",s??""):t.setAttribute(r,s??""))}}r=e=>g(e);const _=e=>{e.node._bindings||(e.node._bindings=[]),e.node._bindings.push(e),g(e)},v=(e,t={})=>{if(3===e.nodeType){const t=m(e.textContent);return void(t!==e.textContent&&(e.textContent=t))}if(1!==e.nodeType)return;if(Array.from(e.attributes).forEach(e=>{if(!e.name.startsWith("$")&&!e.name.startsWith("st-")&&!e.name.startsWith(".")){const t=m(e.value);t!==e.value&&(e.value=t)}}),"TEMPLATE"!==e.tagName&&(e.hasAttribute("$if")||e.hasAttribute("$each")||e.hasAttribute("st-if")||e.hasAttribute("st-each"))){const t=document.createElement("TEMPLATE");return Array.from(e.attributes).filter(t=>["$if","$each","st-if","st-each"].includes(t.name)||(e.hasAttribute("$each")||e.hasAttribute("st-each"))&&["as","index"].includes(t.name)).forEach(n=>{t.setAttribute(n.name,n.value),e.removeAttribute(n.name)}),e.parentNode.insertBefore(t,e),t.content.appendChild(e),t._ref=e._ref,void(e=t)}if("TEMPLATE"===e.tagName&&(e.hasAttribute("$if")||e.hasAttribute("st-if"))&&(e.hasAttribute("$each")||e.hasAttribute("st-each"))){const t=document.createElement("TEMPLATE"),n=Array.from(e.attributes).filter(e=>["$if","$each","st-if","st-each"].includes(e.name)),r=n[n.length-1];t.setAttribute(r.name,r.value),e.removeAttribute(r.name),"$each"!==r.name&&"st-each"!==r.name||Array.from(e.attributes).filter(e=>["as","index"].includes(e.name)).forEach(n=>{t.setAttribute(n.name,n.value),e.removeAttribute(n.name)}),Array.from(e.content.childNodes).forEach(e=>{t.content.appendChild(e)}),e.content.appendChild(t),t._ref=e._ref}if("IMG"===e.tagName&&(e.hasAttribute("src")||e.hasAttribute("_src")||e.hasAttribute("$src"))){const t=e;Promise.resolve().then(()=>{const e=t.getAttribute("_src")||t.getAttribute("src");e&&fetch(e,{cache:"force-cache"}).then(e=>e.text()).then(e=>{const n=(new DOMParser).parseFromString(e,"image/svg+xml").querySelector("svg");n&&(Array.from(t.attributes).forEach(e=>n.setAttribute(e.name,e.value)),t.replaceWith(n))})})}if(void 0!==e._thisObj&&(t.thisObj=e._thisObj||null),void 0===t.thisObj){let n=e;for(;n&&void 0===n._thisObj;)n=n.parentNode;t.thisObj=n?n._thisObj:null}if(void 0===e._ref){let t=e;for(;t&&void 0===t._ref;)t=t.parentNode;e._ref=t?{...t._ref}:{}}void 0===t.extendVars&&(t.extendVars={}),void 0!==e._ref&&(Object.assign(e._ref,t.extendVars),t.extendVars={...e._ref}),((e,t)=>{if(O.exists(e.tagName)&&!e._componentInitialized&&(e._componentInitialized=!0,j(e.tagName,e,t),c(e,"[slot-id]").forEach(e=>e.removeAttribute("slot-id")),e._thisObj||(e._thisObj=e)),e._bindings)return e._bindings.forEach(e=>g(e)),void(e._hasOnUpdate&&e.dispatchEvent(new Event("update",{bubbles:!1})));let n=[];var r;"TEMPLATE"===e.tagName?(e._children=[...e.content.childNodes],e._renderedNodes=[],e.hasAttribute("$if")?n.push(e.getAttributeNode("$if")):e.hasAttribute("$each")?n.push(e.getAttributeNode("$each")):e.hasAttribute("st-if")?n.push(e.getAttributeNode("st-if")):e.hasAttribute("st-each")&&n.push(e.getAttributeNode("st-each"))):n=Array.from(e.attributes).filter(e=>(e.name.startsWith("$")||e.name.startsWith("st-"))&&!["$if","$each","st-if","st-each"].includes(e.name)||e.name.includes(".")),e._thisObj&&t.thisObj&&(e._thisObj.parent=t.thisObj),e._thisObj||(e._thisObj=t.thisObj||null),e._ref||(e._ref=t.extendVars||{}),n.forEach(n=>{const r=n.name.startsWith("$")||n.name.startsWith("st-"),s=r?n.name.slice(n.name.startsWith("$")?1:3):n.name;let a=n.value;var i,d;e.removeAttribute(n.name),s.startsWith(".")?_({node:e,prop:s.split("."),tpl:a,exp:r}):s.startsWith("on")?("onupdate"===s&&(e._hasOnUpdate=!0),"onload"!==s||["BODY","IMG","IFRAME"].includes(e.tagName)||(e._hasOnLoad=!0),"onunload"!==s||["BODY","IMG","IFRAME"].includes(e.tagName)||(e._hasOnUnload=!0),i=e,d=t.thisObj,i.addEventListener(s.slice(2),e=>{u(a,{event:e,thisNode:i,...e.detail||{}},d||i,i._ref||{})})):("bind"===s?e.addEventListener("TEXTAREA"===e.tagName||e.isContentEditable||"text"===e.type||"password"===e.type?"input":"change",n=>{let r=e.isContentEditable?n.target.innerHTML:"checkbox"===e.type?n.target.checked:n.target.files||n.target.value||n.detail;o(e),h(!0),"checkbox"===e.type&&e._checkboxMultiMode?u(`!!checked ? (!${a}.includes(val) && ${a}.push(val)) : (index = ${a}.indexOf(val), index > -1 && ${a}.splice(index, 1))`,{val:e.value,checked:r,thisNode:e},t.thisObj||e,e._ref||{}):u(`${a} = val`,{val:r,thisNode:e},t.thisObj||e,e._ref||{}),h(!1),o(null)}):"text"!==s||a||(a=e.textContent,e.textContent=""),a&&(a=m(a),_({node:e,attr:s,tpl:a,exp:r})))}),(e._hasOnLoad||e._componentInitialized)&&(r=e,Promise.resolve().then(()=>r.dispatchEvent(new Event("load",{bubbles:!1})))),e._hasOnUpdate&&e.dispatchEvent(new Event("update",{bubbles:!1})),e._thisObj&&(t.thisObj=e._thisObj)})(e,t);const n=[...e.childNodes||[]];t.extendVars=e._ref||t.extendVars,n.forEach(n=>v(n,{thisObj:t.thisObj,extendVars:{...e._ref}}))},A=e=>{1===e.nodeType&&(e._hasOnUnload&&e.dispatchEvent(new Event("unload",{bubbles:!1})),e._bindings&&e._bindings.forEach(e=>{e._sets&&(e._sets.forEach(t=>t.delete(e)),e._sets.clear())}),e.childNodes&&e.childNodes.forEach(e=>A(e)))},E=v,y=new Map,N=[],O={getTemplate:e=>document.querySelector(`template[component="${e.toUpperCase()}"]`),register:(e,t,n=null,...r)=>{y.set(e.toUpperCase(),t),"loading"!==document.readyState?O._addTemplate(e,n,r):N.push([e,n,r])},exists:e=>y.has(e.toUpperCase()),getSetupFunction:e=>y.get(e.toUpperCase()),_addTemplate:(e,t,n)=>{if(t){const n=document.createElement("TEMPLATE");n.setAttribute("component",e.toUpperCase()),n.content.appendChild(t),document.body.appendChild(n)}n&&n.forEach(e=>document.body.appendChild(e))},_initPending:()=>{N.forEach(([e,t,n])=>O._addTemplate(e,t,n)),N.length=0}};function T(e,t,n,r={}){e.attributes&&Array.from(e.attributes).forEach(e=>"class"!==e.name&&t.setAttribute(e.name,e.value)),e.classList&&t.classList.add(...e.classList),Array.from(e.childNodes).forEach(e=>t.appendChild(e)),e.tagName&&O.exists(e.tagName)&&j(e.tagName,t,n,r)}function j(e,t,n,r={}){if(r[e])return;r[e]=!0,n.thisObj&&Array.from(t.attributes).forEach(e=>{(e.name.startsWith("$")||e.name.startsWith("st-"))&&e.value.includes("this.")&&(e.value=e.value.replace(/\bthis\./g,"this.parent."))});const s=O.getSetupFunction(e),a={};Array.from(t.childNodes).forEach(e=>{e.nodeType===Node.ELEMENT_NODE&&e.hasAttribute("slot")&&(a[e.getAttribute("slot")]=e,e.removeAttribute("slot"))}),t.innerHTML="",t.state=i(t.state||{});const o=O.getTemplate(e);if(o){const e=o.content.cloneNode(!0);if(e.childNodes.length){T(e.children[0],t,n,r),c(t,"[slot-id]").forEach(e=>{const t=e.getAttribute("slot-id"),s=a[t];s&&(e.removeAttribute("slot-id"),e.innerHTML="","TEMPLATE"===s.tagName?Array.from(s.content.childNodes).forEach(t=>e.appendChild(t.cloneNode(!0))):T(s,e,n,r))})}}if(s)try{s(t)}catch(t){console.error("Error in component setupFunc for",e,t)}}const x={clone:window.structuredClone||(e=>JSON.parse(JSON.stringify(e))),base64:e=>btoa(String.fromCharCode(...(new TextEncoder).encode(e))),unbase64:e=>(new TextDecoder).decode(Uint8Array.from(atob(e),e=>e.charCodeAt(0))),urlbase64:e=>x.base64(e).replace(/[+/=]/g,e=>({"+":"-","/":"","=":""}[e])),unurlbase64:e=>x.unbase64(e.replace(/[-_.]/g,e=>({"-":"+",_:"/",".":"="}[e])).padEnd(4*Math.ceil(e.length/4),"=")),safeJson:e=>{try{return JSON.parse(e)}catch{return null}},updateDefaults:(e,t)=>{for(const n in t)void 0===e[n]&&(e[n]=t[n])},copyFunction:(e,t,...n)=>{n.forEach(n=>e[n]=t[n].bind(t))},getFunctionBody:e=>{const t=e.toString();return t.slice(t.indexOf("{")+1,t.lastIndexOf("}")).trim()},makeDom:e=>{e.includes(">\n")&&(e=e.replace(/>\s+<").trim());const t=document.createElement("div");return t.innerHTML=e,t.children[0]},newAvg:()=>{let e=0,t=0,n=0;return{add:r=>(e+=r,t++,n=e/t),get:()=>n,clear:()=>{e=0,t=0,n=0}}},newTimeCount:()=>{let e=0,t=0,n=0;return{start:()=>e=(new Date).getTime(),end:()=>{const r=(new Date).getTime(),s=r-e;return e=r,t+=s,n++,s},avg:()=>t/n}}};globalThis.Util=x;let w=new URLSearchParams((null==(e=window.location.hash)?void 0:e.substring(1))||"");const S=i({},e=>x.safeJson(w.get(e)),(e,t)=>{const n=w.get(e),r=void 0===t?void 0:JSON.stringify(t);n===r||null===n&&void 0===r||(void 0===t?w.delete(e):w.set(e,r),window.location.hash="#"+w.toString())});"undefined"!=typeof window&&window.addEventListener("hashchange",()=>{var e;const t=w;w=new URLSearchParams((null==(e=window.location.hash)?void 0:e.substring(1))||""),w.forEach((e,n)=>{t.get(n)!==e&&(S[n]=x.safeJson(e))}),t.forEach((e,t)=>{void 0===w.get(t)&&(S[t]=void 0)})});const M=i({},e=>x.safeJson(localStorage.getItem(e)),(e,t)=>{const n=localStorage.getItem(e),r=void 0===t?void 0:JSON.stringify(t);n===r||null===n&&void 0===r||(void 0===t?localStorage.removeItem(e):localStorage.setItem(e,r))});if(globalThis.Hash=S,globalThis.LocalStorage=M,"undefined"!=typeof document){const e=()=>{O._initPending(),new MutationObserver(e=>{e.forEach(e=>{e.addedNodes.forEach(e=>{e.isConnected&&v(e)}),e.removedNodes.forEach(e=>A(e))})}).observe(document.documentElement,{childList:!0,subtree:!0});const e=document.documentElement;e.hasAttribute("$data-bs-theme")||e.hasAttribute("data-bs-theme")||e.setAttribute("$data-bs-theme","LocalStorage.darkMode?'dark':'light'"),v(document.documentElement)};"loading"!==document.readyState?e():document.addEventListener("DOMContentLoaded",e,!0)}export{d as $,c as $$,O as Component,S as Hash,M as LocalStorage,i as NewState,E as RefreshState,b as SetTranslator,x as Util,v as _scanTree,A as _unbindTree}; diff --git a/package.json b/package.json index 1cf8f99..9e3c79d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@web/state", - "version": "1.0.0", + "version": "1.0.1", "type": "module", "main": "dist/state.js", diff --git a/src/component.js b/src/component.js index 6171add..e7256a3 100644 --- a/src/component.js +++ b/src/component.js @@ -31,10 +31,14 @@ export const Component = { }; export function _mergeNode(from, to, scanObj, exists = {}) { - Array.from(from.attributes).forEach(attr => attr.name !== 'class' && to.setAttribute(attr.name, attr.value)); - to.classList.add(...from.classList); + if (from.attributes) { + Array.from(from.attributes).forEach(attr => attr.name !== 'class' && to.setAttribute(attr.name, attr.value)); + } + if (from.classList) { + to.classList.add(...from.classList); + } Array.from(from.childNodes).forEach(child => to.appendChild(child)); - if (Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists); + if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists); } export function _makeComponent(name, node, scanObj, exists = {}) { @@ -59,13 +63,24 @@ export function _makeComponent(name, node, scanObj, exists = {}) { _mergeNode(rootNode, node, scanObj, exists); $$(node, '[slot-id]').forEach(placeholder => { const slotName = placeholder.getAttribute('slot-id'); - if (slots[slotName]) { + const slotSource = slots[slotName]; + if (slotSource) { placeholder.removeAttribute('slot-id'); placeholder.innerHTML = ''; - _mergeNode(slots[slotName], placeholder, scanObj, exists); + if (slotSource.tagName === 'TEMPLATE') { + Array.from(slotSource.content.childNodes).forEach(child => placeholder.appendChild(child.cloneNode(true))); + } else { + _mergeNode(slotSource, placeholder, scanObj, exists); + } } }); } } - if (componentFunc) componentFunc(node); + if (componentFunc) { + try { + componentFunc(node); + } catch (e) { + console.error('Error in component setupFunc for', name, e); + } + } } diff --git a/src/dom.js b/src/dom.js index 3a08d14..cc483e5 100644 --- a/src/dom.js +++ b/src/dom.js @@ -189,21 +189,11 @@ export function _updateBinding(binding) { export const _initBinding = (binding) => { if (!binding.node._bindings) binding.node._bindings = []; - binding.node._bindings.push({ attr: binding.attr, prop: binding.prop, tpl: binding.tpl, exp: binding.exp }); + binding.node._bindings.push(binding); _updateBinding(binding); }; export const _parseNode = (node, scanObj) => { - if (node._bindings) { - node._states = new Set(); - node._bindings.forEach(bindingData => { - const binding = { node: node, ...bindingData }; - _updateBinding(binding); - }); - if (node._hasOnUpdate) node.dispatchEvent(new Event('update', { bubbles: false })); - return; - } - if (Component.exists(node.tagName) && !node._componentInitialized) { node._componentInitialized = true; _makeComponent(node.tagName, node, scanObj); @@ -211,6 +201,12 @@ export const _parseNode = (node, scanObj) => { if (!node._thisObj) node._thisObj = node; } + if (node._bindings) { + node._bindings.forEach(binding => _updateBinding(binding)); + if (node._hasOnUpdate) node.dispatchEvent(new Event('update', { bubbles: false })); + return; + } + let attrs = []; if (node.tagName === 'TEMPLATE') { node._children = [...node.content.childNodes]; @@ -226,7 +222,6 @@ export const _parseNode = (node, scanObj) => { if (node._thisObj && scanObj.thisObj) node._thisObj.parent = scanObj.thisObj; if (!node._thisObj) node._thisObj = scanObj.thisObj || null; if (!node._ref) node._ref = scanObj.extendVars || {}; - node._states = new Set(); attrs.forEach(attr => { const exp = attr.name.startsWith('$') || attr.name.startsWith('st-'); @@ -365,12 +360,11 @@ export const _scanTree = (node, scanObj = {}) => { export const _unbindTree = (node) => { if (node.nodeType !== 1) return; if (node._hasOnUnload) node.dispatchEvent(new Event('unload', { bubbles: false })); - if (node._states) { - node._states.forEach(stateMappings => { - for (const [key, bindingSet] of stateMappings) { - for (const binding of bindingSet) { - if (binding.node === node) bindingSet.delete(binding); - } + if (node._bindings) { + node._bindings.forEach(binding => { + if (binding._sets) { + binding._sets.forEach(set => set.delete(binding)); + binding._sets.clear(); } }); } diff --git a/src/observer.js b/src/observer.js index 59821e5..613c406 100644 --- a/src/observer.js +++ b/src/observer.js @@ -2,6 +2,8 @@ let _activeBinding = null; let _noWriteBack = null; let _updateBindingFn = null; +let _updateDepth = 0; +const MAX_UPDATE_DEPTH = 100; export function getActiveBinding() { return _activeBinding; } export function setActiveBinding(val) { _activeBinding = val; } @@ -34,9 +36,10 @@ export function NewState(defaults = {}, getter = null, setter = null) { if (key === '__unwatch') return _unwatchFunc; if (_activeBinding) { if (!_stateMappings.has(key)) _stateMappings.set(key, new Set()); - _stateMappings.get(key).add(_activeBinding); - if (!_activeBinding.node._states) _activeBinding.node._states = new Set(); - _activeBinding.node._states.add(_stateMappings); + const bindingSet = _stateMappings.get(key); + bindingSet.add(_activeBinding); + if (!_activeBinding._sets) _activeBinding._sets = new Set(); + _activeBinding._sets.add(bindingSet); } return _getter(key); }, @@ -57,15 +60,21 @@ export function NewState(defaults = {}, getter = null, setter = null) { _watchers.get(null).forEach(cb => cb(value)); } if (_stateMappings.has(key)) { - const bindings = _stateMappings.get(key); - for (const binding of bindings) { - if (!binding.node.isConnected) { - bindings.delete(binding); - continue; - } - if (_noWriteBack !== binding.node && _updateBindingFn) { - _updateBindingFn(binding); + if (_updateDepth > MAX_UPDATE_DEPTH) return console.error('Recursive update detected at key:', key), true; + _updateDepth++; + try { + const bindings = _stateMappings.get(key); + for (const binding of bindings) { + if (!binding.node.isConnected) { + bindings.delete(binding); + continue; + } + if (_noWriteBack !== binding.node && _updateBindingFn) { + _updateBindingFn(binding); + } } + } finally { + _updateDepth--; } } return true; diff --git a/test/all.spec.js b/test/all.spec.js index cd58851..eb9e31f 100644 --- a/test/all.spec.js +++ b/test/all.spec.js @@ -1,15 +1,24 @@ import { test, expect } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; test('modular unit tests and benchmark', async ({ page }) => { page.on('console', msg => console.log('BROWSER LOG:', msg.text())); await page.goto('http://localhost:8081/test/index.html'); - // Wait for testStatus to be set await page.waitForFunction(() => window.testStatus !== undefined, { timeout: 10000 }); - const status = await page.evaluate(() => window.testStatus); expect(status).toBe('passed'); + // Read benchmarks from TEST.md + const testMd = fs.readFileSync(path.join(process.cwd(), 'TEST.md'), 'utf-8'); + const getBench = (name) => { + const match = testMd.match(new RegExp(`\\*\\*${name}\\*\\*\\s*\\|\\s*([\\d.]+)`)); + return match ? parseFloat(match[1]) : null; + }; + const baseInitial = getBench('首次渲染 \\(1000 items\\)'); + const baseUpdate = getBench('浅更新 \\(Shallow Update\\)'); + // Benchmark: Large list rendering const renderTime = await page.evaluate(async () => { const start = performance.now(); @@ -28,16 +37,27 @@ test('modular unit tests and benchmark', async ({ page }) => { return performance.now() - start; }); console.log(`BENCHMARK: 1000 items initial render: ${renderTime.toFixed(2)}ms`); + if (baseInitial) expect(renderTime).toBeLessThan(baseInitial * 1.2); // Benchmark: Large list update const updateTime = await page.evaluate(async () => { const start = performance.now(); window.state.benchItems[0].val = 'updated'; - // Note: Shallow proxy requires reassignment or internal trigger if we modified a deep property. - // But here we modify benchItems[0], which is an object inside the array. - // Our current observer.js might not catch this if it's benchItems[0] = ... window.state.benchItems = [...window.state.benchItems]; return performance.now() - start; }); console.log(`BENCHMARK: 1000 items update (shallow): ${updateTime.toFixed(2)}ms`); + if (baseUpdate) expect(updateTime).toBeLessThan(baseUpdate * 1.2); + + // Extreme Data Test + await page.evaluate(async () => { + const { RefreshState } = await import('@web/state'); + document.body.innerHTML = '
'; + window.state.extreme = null; + RefreshState(document.getElementById('extreme')); + window.state.extreme = undefined; + window.state.extreme = { a: 1 }; + window.state.extreme = [1, 2]; + window.state.extreme = "not iterable"; + }); });