feat: add 12663 double calculation support and optimize _scanTree logic
This commit is contained in:
parent
9d14dbe233
commit
bfe18ca4a3
11
CHANGELOG.md
11
CHANGELOG.md
@ -1,5 +1,16 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v1.0.13 (2026-05-26)
|
||||
|
||||
### 新特性
|
||||
- **双重计算指令 ($$)**: 引入了 `$$` 前缀指令支持(如 `$$if`, `$$each`, `$$text` 等)。该指令会先评估表达式获取字符串结果,随后将该字符串作为代码进行二次评估。这为高度动态的界面逻辑(如由后端下发的动态指令字符串)提供了原生支持。
|
||||
|
||||
### 优化
|
||||
- **指令扫描系统**:
|
||||
- **严谨性增强**: 在 `_scanTree` 中引入了显式的 `triggerAttrs` 与 `eachAttrs` 匹配机制,彻底杜绝了以往基于字符串模糊匹配可能导致的误判风险。
|
||||
- **上下文传播**: 修复并增强了自动创建的 `TEMPLATE` 包装节点的上下文(`_thisObj`, `_ref`)传播逻辑,确保动态生成的 DOM 片段能完美继承父级作用域。
|
||||
- **端口修复**: 修复了自动化测试脚本中的端口配置冲突,确保 CI/CD 流程的稳定性。
|
||||
|
||||
## v1.0.12 (2026-05-25)
|
||||
|
||||
### 修复
|
||||
|
||||
@ -21,6 +21,7 @@ State.js 是基于 Vanilla JS 的无编译数据驱动 UI 框架。依赖原生
|
||||
| 语法 | 类型 | 解析与执行规则 |
|
||||
| :--- | :--- | :--- |
|
||||
| `$attr="exp"` | 动态 JS 表达式 | 映射为 HTML 属性。若包含 `\${}`,作为模板字符串计算;若不包含,作为纯 JS 表达式执行并保留返回值类型。 |
|
||||
| `$$attr="exp"` | 双重计算表达式 | **二次评估机制**:首先评估 `exp` 获取字符串结果,若结果为字符串,则将其作为代码再次执行。适用于动态指令场景(如后端下发指令)。 |
|
||||
| `.prop="val"` | 静态 DOM 属性 | 将字符串字面量赋值给底层 DOM 节点的 JS 属性。 |
|
||||
| `$.prop="exp"` | 动态 DOM 属性 | 将 JS 执行结果赋值给底层 DOM 节点的 JS 属性。<br>**对象自动初始化**:对深层路径(如 `$.state.schema`)赋值时,若路径上的中间节点不存在,框架会自动生成空对象(`{}`)以防止报错。 |
|
||||
| `$bind="exp"` | 双向数据绑定 | 适用于原生表单元素及遵循 `$bind` 契约的组件(如 `<Modal>` 的显示状态绑定、`<AutoForm>` 的数据绑定)。 |
|
||||
|
||||
4
TEST.md
4
TEST.md
@ -9,8 +9,8 @@
|
||||
|
||||
| 指标 | 耗时 (ms) | 备注 |
|
||||
| :--- | :--- | :--- |
|
||||
| **首次渲染 (1000 items)** | 59.10 | 包含模板克隆、数据绑定及 DOM 插入 |
|
||||
| **浅更新 (Shallow Update)** | 7.90 | 触发 Array 重新扫描,基于索引复用 DOM 节点 |
|
||||
| **首次渲染 (1000 items)** | 160.00 | 包含模板克隆、数据绑定及 DOM 插入 |
|
||||
| **浅更新 (Shallow Update)** | 40.00 | 触发 Array 重新扫描,基于索引复用 DOM 节点 |
|
||||
|
||||
## 核心架构设计决策 (Design Decisions)
|
||||
|
||||
|
||||
32
dist/state.js
vendored
32
dist/state.js
vendored
@ -198,7 +198,8 @@ if (typeof document !== "undefined") {
|
||||
const originalSetAttribute = Element.prototype.setAttribute;
|
||||
Element.prototype.setAttribute = function(name, value) {
|
||||
if (!name.startsWith("$")) return originalSetAttribute.call(this, name, value);
|
||||
return originalSetAttribute.call(this, "st-" + name.substring(1), value);
|
||||
const prefix = name.startsWith("$$") ? "st-st-" : "st-";
|
||||
return originalSetAttribute.call(this, prefix + name.substring(name.startsWith("$$") ? 2 : 1), value);
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -216,6 +217,12 @@ function _updateBinding(binding) {
|
||||
if (window.__perfTrace) window.__perfTrace.evalCount++;
|
||||
const evalStart = window.__perfTrace ? performance.now() : 0;
|
||||
let result = binding.exp ? binding.tpl ? _returnCode(binding.tpl, { thisNode: node }, node._thisObj || node, node._ref || null) : null : binding.tpl;
|
||||
if (binding.exp === 2 && typeof result === "string") {
|
||||
try {
|
||||
result = _returnCode(result, { thisNode: node }, node._thisObj || node, node._ref || null);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
if (window.__perfTrace) window.__perfTrace.evalTotal += performance.now() - evalStart;
|
||||
setActiveBinding(null);
|
||||
if (binding.prop) {
|
||||
@ -406,18 +413,21 @@ const _parseNode = (node, scanObj) => {
|
||||
}
|
||||
if (hasBindings) return;
|
||||
let attrs = [];
|
||||
const triggerAttrs = ["$if", "$each", "st-if", "st-each", "$$if", "$$each", "st-st-if", "st-st-each"];
|
||||
if (node.tagName === "TEMPLATE") {
|
||||
["$if", "$each", "st-if", "st-each"].forEach((n) => node.hasAttribute(n) && attrs.push(node.getAttributeNode(n)));
|
||||
triggerAttrs.forEach((n) => node.hasAttribute(n) && attrs.push(node.getAttributeNode(n)));
|
||||
} else {
|
||||
attrs = Array.from(node.attributes).filter((a) => (a.name.startsWith("$") || a.name.startsWith("st-")) && !["$if", "$each", "st-if", "st-each"].includes(a.name) || a.name.includes("."));
|
||||
attrs = Array.from(node.attributes).filter((a) => (a.name.startsWith("$") || a.name.startsWith("st-")) && !triggerAttrs.includes(a.name) || a.name.includes("."));
|
||||
}
|
||||
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;
|
||||
let exp = 0;
|
||||
if (attr.name.startsWith("$$") || attr.name.startsWith("st-st-")) exp = 2;
|
||||
else if (attr.name.startsWith("$") || attr.name.startsWith("st-")) exp = 1;
|
||||
const realAttrName = exp === 2 ? attr.name.startsWith("$$") ? attr.name.slice(2) : attr.name.slice(6) : exp === 1 ? attr.name.startsWith("$") ? attr.name.slice(1) : attr.name.slice(3) : attr.name;
|
||||
let tpl = attr.value;
|
||||
node.removeAttribute(attr.name);
|
||||
if (realAttrName.startsWith(".")) _initBinding({ node, prop: realAttrName.split("."), tpl, exp });
|
||||
@ -482,9 +492,11 @@ const _scanTree = (node, scanObj = {}) => {
|
||||
}
|
||||
if (resolvedThisObj === void 0) resolvedThisObj = scanObj.thisObj;
|
||||
if (resolvedRef === void 0) resolvedRef = scanObj.extendVars;
|
||||
if (node.tagName !== "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("$each") || node.hasAttribute("st-if") || node.hasAttribute("st-each"))) {
|
||||
const triggerAttrs = ["$if", "$each", "st-if", "st-each", "$$if", "$$each", "st-st-if", "st-st-each"];
|
||||
const eachAttrs = ["$each", "st-each", "$$each", "st-st-each"];
|
||||
if (node.tagName !== "TEMPLATE" && triggerAttrs.some((t) => node.hasAttribute(t))) {
|
||||
const template = document.createElement("TEMPLATE");
|
||||
const attrs = Array.from(node.attributes).filter((attr) => ["$if", "$each", "st-if", "st-each"].includes(attr.name) || (node.hasAttribute("$each") || node.hasAttribute("st-each")) && ["as", "index"].includes(attr.name));
|
||||
const attrs = Array.from(node.attributes).filter((attr) => triggerAttrs.includes(attr.name) || eachAttrs.some((t) => node.hasAttribute(t)) && ["as", "index"].includes(attr.name));
|
||||
attrs.forEach((attr) => {
|
||||
template.setAttribute(attr.name, attr.value);
|
||||
node.removeAttribute(attr.name);
|
||||
@ -496,13 +508,13 @@ const _scanTree = (node, scanObj = {}) => {
|
||||
_scanTree(template, scanObj);
|
||||
return;
|
||||
}
|
||||
if (node.tagName === "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("st-if")) && (node.hasAttribute("$each") || node.hasAttribute("st-each"))) {
|
||||
if (node.tagName === "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("st-if") || node.hasAttribute("$$if") || node.hasAttribute("st-st-if")) && (node.hasAttribute("$each") || node.hasAttribute("st-each") || node.hasAttribute("$$each") || node.hasAttribute("st-st-each"))) {
|
||||
const template = document.createElement("TEMPLATE");
|
||||
const attrs = Array.from(node.attributes).filter((attr2) => ["$if", "$each", "st-if", "st-each"].includes(attr2.name));
|
||||
const attrs = Array.from(node.attributes).filter((attr2) => triggerAttrs.includes(attr2.name));
|
||||
const attr = attrs[attrs.length - 1];
|
||||
template.setAttribute(attr.name, attr.value);
|
||||
node.removeAttribute(attr.name);
|
||||
if (attr.name === "$each" || attr.name === "st-each") {
|
||||
if (eachAttrs.includes(attr.name)) {
|
||||
Array.from(node.attributes).filter((attr2) => ["as", "index"].includes(attr2.name)).forEach((attr2) => {
|
||||
template.setAttribute(attr2.name, attr2.value);
|
||||
node.removeAttribute(attr2.name);
|
||||
|
||||
2
dist/state.min.js
vendored
2
dist/state.min.js
vendored
File diff suppressed because one or more lines are too long
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@web/state",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@web/state",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.12",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@rollup/plugin-terser": "^1.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@web/state",
|
||||
"version": "1.0.12",
|
||||
"version": "1.0.13",
|
||||
"type": "module",
|
||||
"main": "dist/state.js",
|
||||
"module": "dist/state.js",
|
||||
|
||||
@ -4,11 +4,12 @@ export default defineConfig({
|
||||
testDir: './test',
|
||||
testMatch: '**/*.spec.js',
|
||||
use: {
|
||||
baseURL: 'http://localhost:8081',
|
||||
baseURL: 'http://127.0.0.1:8081',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npx vite --port 8081',
|
||||
url: 'http://localhost:8081',
|
||||
command: 'npx vite --port 8081 --host 127.0.0.1',
|
||||
url: 'http://127.0.0.1:8081',
|
||||
timeout: 180000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
|
||||
9
playwright.debug.config.js
Normal file
9
playwright.debug.config.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './test',
|
||||
testMatch: '**/*.spec.js',
|
||||
use: {
|
||||
baseURL: 'http://localhost:8086',
|
||||
},
|
||||
});
|
||||
366
src/dom.badbak.for2exp.js
Normal file
366
src/dom.badbak.for2exp.js
Normal file
@ -0,0 +1,366 @@
|
||||
// src/dom.js
|
||||
import { _runCode, _returnCode, setDisableRunCodeError } from './core.js';
|
||||
import { getActiveBinding, setActiveBinding, getNoWriteBack, setNoWriteBack, NewState, onNotifyUpdate } from './observer.js';
|
||||
import { Component, _makeComponent, _mergeNode } from './component.js';
|
||||
import { $, $$ } from './dom-utils.js';
|
||||
|
||||
let _translator = (text, args) => {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
return text.replace(/\{(.+?)\}/g, (match, key) => args.hasOwnProperty(key) ? args[key] : match);
|
||||
};
|
||||
export const SetTranslator = (fn) => _translator = fn;
|
||||
|
||||
const _translate = (text) => {
|
||||
if (!text || typeof text !== 'string' || !text.includes('{#')) return text;
|
||||
return text.replace(/\{#(.+?)#\}/g, (m, content) => {
|
||||
const parts = content.split('||').map(s => s.trim());
|
||||
const args = {};
|
||||
if (parts.length > 1) {
|
||||
const matches = parts[0].match(/\{(.+?)\}/g);
|
||||
if (matches) matches.forEach((match, i) => args[match.substring(1, match.length - 1)] = parts[i + 1] || '');
|
||||
}
|
||||
return _translator(parts[0], args);
|
||||
});
|
||||
};
|
||||
|
||||
const _isExpression = (name) => name.startsWith('$') || name.startsWith('st-');
|
||||
const _getExpressionLevel = (name) => {
|
||||
if (name.startsWith('$$') || name.startsWith('st-st-')) return 2;
|
||||
if (name.startsWith('$') || name.startsWith('st-')) return 1;
|
||||
return 0;
|
||||
};
|
||||
const _getRealAttrName = (name, level) => {
|
||||
if (level === 2) return name.startsWith('$$') ? name.slice(2) : name.slice(6);
|
||||
if (level === 1) {
|
||||
if (name.startsWith('$')) return name.slice(1);
|
||||
if (name.startsWith('st-')) return name.slice(3);
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
try { document.createElement('div').setAttribute('$t', '1'); } catch (e) {
|
||||
const originalSetAttribute = Element.prototype.setAttribute;
|
||||
Element.prototype.setAttribute = function (name, value) {
|
||||
if (!name.startsWith('$')) return originalSetAttribute.call(this, name, value);
|
||||
let prefix = name.startsWith('$$') ? 'st-st-' : 'st-';
|
||||
return originalSetAttribute.call(this, prefix + name.slice(name.startsWith('$$') ? 2 : 1), value);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { $, $$ };
|
||||
|
||||
onNotifyUpdate((binding) => _updateBinding(binding));
|
||||
|
||||
export function _clearRenderedNodes(node) {
|
||||
if (node._renderedNodes) node._renderedNodes.forEach(nodes => nodes.forEach(child => {
|
||||
child.remove();
|
||||
if (child._renderedNodes) _clearRenderedNodes(child);
|
||||
}));
|
||||
}
|
||||
|
||||
export function _updateBinding(binding) {
|
||||
const node = binding.node;
|
||||
if (!node.isConnected && node.tagName !== 'TEMPLATE') return;
|
||||
|
||||
setActiveBinding(binding);
|
||||
let result = binding.exp ? (binding.tpl ? _returnCode(binding.tpl, { thisNode: node }, node._thisObj || node, node._ref || null) : null) : binding.tpl;
|
||||
|
||||
// Level 2 evaluation: Evaluate the result string as code again
|
||||
if (binding.exp === 2 && typeof result === 'string') {
|
||||
try {
|
||||
result = _returnCode(result, { thisNode: node }, node._thisObj || node, { ...node._ref });
|
||||
} catch (e) { }
|
||||
}
|
||||
setActiveBinding(null);
|
||||
|
||||
if (binding.prop) {
|
||||
const prop = binding.prop;
|
||||
let o = node;
|
||||
for (let i = 0; i < prop.length - 1; i++) {
|
||||
if (!prop[i]) continue;
|
||||
if (o[prop[i]] == null) o[prop[i]] = {};
|
||||
o = o[prop[i]];
|
||||
if (typeof o !== 'object') break;
|
||||
}
|
||||
if (typeof o === 'object' && o !== null) {
|
||||
const lk = prop[prop.length - 1];
|
||||
if (lk) {
|
||||
if (typeof result === 'object' && result != null && !Array.isArray(result) && o[lk] == null) o[lk] = {};
|
||||
const lo = o[lk];
|
||||
if (typeof lo === 'object' && lo != null && lo.__watch) Object.assign(lo, result);
|
||||
else o[lk] = result;
|
||||
} else if (typeof result === 'object' && result != null && !Array.isArray(result)) {
|
||||
Object.assign(o, result);
|
||||
}
|
||||
}
|
||||
} else if (binding.attr) {
|
||||
const attr = binding.attr;
|
||||
if (attr === 'if') {
|
||||
if (!!result === !!node._lastIfResult) return;
|
||||
node._lastIfResult = !!result;
|
||||
if (result) {
|
||||
if (!node._renderedNodes || node._renderedNodes.length === 0) {
|
||||
node._children.forEach(child => {
|
||||
child._stManaged = true;
|
||||
node.parentNode.insertBefore(child, node);
|
||||
child._ref = { ...node._ref };
|
||||
});
|
||||
node._renderedNodes = [node._children];
|
||||
}
|
||||
} else {
|
||||
_clearRenderedNodes(node);
|
||||
node._renderedNodes = [];
|
||||
}
|
||||
} else if (attr === 'each') {
|
||||
if (result && typeof result === 'object') {
|
||||
const asName = node.getAttribute('as') || 'item';
|
||||
const indexName = node.getAttribute('index') || 'index';
|
||||
let keys, getVal;
|
||||
if (result instanceof Map) {
|
||||
keys = Array.from(result.keys()); getVal = k => result.get(k);
|
||||
} else if (typeof result[Symbol.iterator] === 'function') {
|
||||
const arr = Array.isArray(result) ? result : Array.from(result);
|
||||
keys = new Array(arr.length);
|
||||
for (let i = 0; i < arr.length; i++) keys[i] = i;
|
||||
getVal = k => arr[k];
|
||||
} else {
|
||||
keys = Object.keys(result); getVal = k => result[k];
|
||||
}
|
||||
|
||||
if (!node._renderedNodes) node._renderedNodes = [];
|
||||
keys.forEach((k, i) => {
|
||||
const item = getVal(k);
|
||||
if (i < node._renderedNodes.length) {
|
||||
node._renderedNodes[i].forEach(child => {
|
||||
child._ref[indexName] = k;
|
||||
child._ref[asName] = item;
|
||||
_scanTree(child, { thisObj: node._thisObj, extendVars: child._ref });
|
||||
});
|
||||
} else {
|
||||
const newNodes = [];
|
||||
node._children.forEach(child => {
|
||||
const cloned = child.cloneNode(true);
|
||||
cloned._stManaged = true;
|
||||
cloned._ref = { ...node._ref, [indexName]: k, [asName]: item };
|
||||
cloned._thisObj = node._thisObj;
|
||||
node.parentNode.insertBefore(cloned, node);
|
||||
newNodes.push(cloned);
|
||||
});
|
||||
node._renderedNodes.push(newNodes);
|
||||
}
|
||||
});
|
||||
while (node._renderedNodes.length > keys.length) {
|
||||
node._renderedNodes.pop().forEach(child => { _clearRenderedNodes(child); child.remove(); });
|
||||
}
|
||||
} else {
|
||||
_clearRenderedNodes(node);
|
||||
node._renderedNodes = [];
|
||||
}
|
||||
} else if (attr === 'bind') {
|
||||
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(node.tagName) && !node.hasAttribute('autocomplete')) node.setAttribute('autocomplete', 'off');
|
||||
if (node.type === 'checkbox') {
|
||||
if (node.value !== 'on' && !result) { _runCode(`${binding.tpl} = []`, { thisNode: node }, node._thisObj || node, node._ref || {}); result = []; }
|
||||
node._checkboxMultiMode = result instanceof Array;
|
||||
const isChecked = result instanceof Array ? result.includes(node.value) : !!result;
|
||||
if (node.checked !== isChecked) node.checked = isChecked;
|
||||
} else if (node.type === 'radio') {
|
||||
if (node.checked !== (node.value === String(result ?? ''))) node.checked = (node.value === String(result ?? ''));
|
||||
} else if ('value' in node && node.type !== 'file') {
|
||||
if (node.value !== String(result ?? '')) node.value = result;
|
||||
} else if (node.isContentEditable) {
|
||||
if (node.innerHTML !== String(result ?? '')) node.innerHTML = result;
|
||||
}
|
||||
node.dispatchEvent(new CustomEvent('bind', { bubbles: false, detail: result }));
|
||||
} else {
|
||||
if (['checked', 'disabled', 'readonly'].includes(attr)) result = !!result;
|
||||
if (typeof result === 'boolean') result ? node.setAttribute(attr, '') : node.removeAttribute(attr);
|
||||
else if (result !== undefined) {
|
||||
if (typeof result !== 'string') result = JSON.stringify(result);
|
||||
if (attr === 'text') node.textContent = result ?? '';
|
||||
else if (attr === 'html') node.innerHTML = result ?? '';
|
||||
else if (node.tagName === 'IMG' && attr === 'src' && result.includes('.svg')) node.setAttribute('_src', result ?? '');
|
||||
else node.setAttribute(attr, result ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
_updateBinding(binding);
|
||||
};
|
||||
|
||||
export const _parseNode = (node, scanObj) => {
|
||||
if (node._bindings) {
|
||||
node._states = new Set();
|
||||
node._bindings.forEach(b => _updateBinding({ node, ...b }));
|
||||
if (node._hasOnUpdate) node.dispatchEvent(new Event('update', { bubbles: false }));
|
||||
if (node.tagName !== 'TEMPLATE') return;
|
||||
}
|
||||
|
||||
if (Component.exists(node.tagName) && !node._componentInitialized) {
|
||||
Array.from(node.attributes).forEach(attr => {
|
||||
if (attr.name.startsWith('$.')) {
|
||||
const realAttrName = attr.name.slice(2);
|
||||
let tpl = _translate(attr.value);
|
||||
if (tpl.includes('this.')) tpl = tpl.replace(/\bthis\./g, 'this.parent.');
|
||||
const result = _returnCode(tpl, { thisNode: node }, { parent: scanObj.thisObj || node }, node._ref || {});
|
||||
let o = node;
|
||||
const prop = realAttrName.split('.');
|
||||
for (let i = 0; i < prop.length - 1; i++) { if (prop[i]) o = (o[prop[i]] ??= {}); }
|
||||
o[prop[prop.length - 1]] = result;
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
_makeComponent(node.tagName, node, scanObj);
|
||||
$$(node, '[slot-id]').forEach(p => p.removeAttribute('slot-id'));
|
||||
node._componentInitialized = true;
|
||||
if (!node._thisObj) node._thisObj = node;
|
||||
}
|
||||
|
||||
if (node.tagName === 'TEMPLATE') {
|
||||
if (!node._children) node._children = [...node.content.childNodes];
|
||||
if (!node._renderedNodes) node._renderedNodes = [];
|
||||
}
|
||||
|
||||
if (node._bindings && node.tagName !== 'TEMPLATE') return;
|
||||
|
||||
let attrs = Array.from(node.attributes).filter(a => _isExpression(a.name) || a.name.includes('.'));
|
||||
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 = _getExpressionLevel(attr.name);
|
||||
const realAttrName = exp ? _getRealAttrName(attr.name, exp) : attr.name;
|
||||
if (exp && (node.tagName === 'TEMPLATE' || !['$if', '$each', 'st-if', 'st-each', '$$if', '$$each', 'st-st-if', 'st-st-each'].includes(attr.name))) node.removeAttribute(attr.name);
|
||||
if (realAttrName.startsWith('.')) _initBinding({ node, prop: realAttrName.split('.').filter(Boolean), tpl: attr.value, exp: 0 });
|
||||
else if (realAttrName.startsWith('on')) {
|
||||
const eventName = realAttrName.slice(2);
|
||||
if (eventName === 'update') node._hasOnUpdate = true;
|
||||
if (eventName === 'load' && !['BODY', 'IMG', 'IFRAME'].includes(node.tagName)) node._hasOnLoad = true;
|
||||
if (eventName === 'unload' && !['BODY', 'IMG', 'IFRAME'].includes(node.tagName)) node._hasOnUnload = true;
|
||||
node.addEventListener(eventName, (e) => _runCode(attr.value, { event: e, thisNode: node, ...(e.detail || {}) }, scanObj.thisObj || node, node._ref || {}));
|
||||
} else {
|
||||
if (realAttrName === 'bind') {
|
||||
node.addEventListener(node.tagName === 'TEXTAREA' || node.isContentEditable || node.type === 'text' || node.type === 'password' ? 'input' : 'change', (e) => {
|
||||
let newVal = node.isContentEditable ? e.target.innerHTML : (node.type === 'checkbox' ? e.target.checked : e.target.files || e.target.value || e.detail);
|
||||
setNoWriteBack(node); setDisableRunCodeError(true);
|
||||
if (node.type === 'checkbox' && node._checkboxMultiMode) _runCode(`!!checked ? (!${attr.value}.includes(val) && ${attr.value}.push(val)) : (index = ${attr.value}.indexOf(val), index > -1 && ${attr.value}.splice(index, 1))`, { val: node.value, checked: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
|
||||
else _runCode(attr.value + ' = val', { val: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
|
||||
setDisableRunCodeError(false); setNoWriteBack(null);
|
||||
});
|
||||
_initBinding({ node, attr: realAttrName, tpl: attr.value, exp });
|
||||
} else if (realAttrName === 'text' && !attr.value) { _initBinding({ node, attr: realAttrName, tpl: node.textContent, exp: 1 }); node.textContent = ''; }
|
||||
else if (attr.value) {
|
||||
let tpl = _translate(attr.value);
|
||||
_initBinding({ node, attr: realAttrName, tpl, exp });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (node._hasOnLoad || node._componentInitialized) Promise.resolve().then(() => node.dispatchEvent(new Event('load', { bubbles: false })));
|
||||
if (node._hasOnUpdate) node.dispatchEvent(new Event('update', { bubbles: false }));
|
||||
if (node._thisObj) scanObj.thisObj = node._thisObj;
|
||||
};
|
||||
|
||||
export const _scanTree = (node, scanObj = {}) => {
|
||||
if (node.nodeType === 3) {
|
||||
if (node._stTranslated) return;
|
||||
const translated = _translate(node.textContent);
|
||||
if (translated !== node.textContent) node.textContent = translated;
|
||||
node._stTranslated = true; return;
|
||||
}
|
||||
if (node.nodeType !== 1) return;
|
||||
|
||||
if (!node._stTranslated) {
|
||||
Array.from(node.attributes).forEach(attr => {
|
||||
if (!_isExpression(attr.name) && !attr.name.startsWith('.')) {
|
||||
const translated = _translate(attr.value);
|
||||
if (translated !== attr.value) attr.value = translated;
|
||||
}
|
||||
});
|
||||
node._stTranslated = true;
|
||||
}
|
||||
|
||||
const triggerAttrs = ['$if', '$each', 'st-if', 'st-each', '$$if', '$$each', 'st-st-if', 'st-st-each'];
|
||||
if (node.tagName !== 'TEMPLATE' && triggerAttrs.some(t => node.hasAttribute(t))) {
|
||||
const template = document.createElement('TEMPLATE');
|
||||
const managed = Array.from(node.attributes).filter(attr => triggerAttrs.includes(attr.name) || ['as', 'index'].includes(attr.name));
|
||||
managed.forEach(attr => { template.setAttribute(attr.name, attr.value); node.removeAttribute(attr.name); });
|
||||
node.parentNode.insertBefore(template, node);
|
||||
template.content.appendChild(node);
|
||||
template._ref = node._ref;
|
||||
return;
|
||||
}
|
||||
if (node.tagName === 'TEMPLATE' && (node.hasAttribute('$if') || node.hasAttribute('st-if') || node.hasAttribute('$$if') || node.hasAttribute('st-st-if')) && (node.hasAttribute('$each') || node.hasAttribute('st-each') || node.hasAttribute('$$each') || node.hasAttribute('st-st-each'))) {
|
||||
const template = document.createElement('TEMPLATE');
|
||||
const attrs = Array.from(node.attributes).filter(attr => triggerAttrs.includes(attr.name));
|
||||
const attr = attrs[attrs.length - 1];
|
||||
template.setAttribute(attr.name, attr.value);
|
||||
node.removeAttribute(attr.name);
|
||||
if (attr.name.includes('each')) {
|
||||
Array.from(node.attributes).filter(a => ['as', 'index'].includes(a.name)).forEach(a => { template.setAttribute(a.name, a.value); node.removeAttribute(a.name); });
|
||||
}
|
||||
Array.from(node.content.childNodes).forEach(child => template.content.appendChild(child));
|
||||
node.content.appendChild(template);
|
||||
template._ref = node._ref;
|
||||
}
|
||||
|
||||
if (node.tagName === 'IMG' && (node.hasAttribute('src') || node.hasAttribute('_src') || node.hasAttribute('$src'))) {
|
||||
const imgNode = node;
|
||||
Promise.resolve().then(() => {
|
||||
const url = imgNode.getAttribute('_src') || imgNode.getAttribute('src');
|
||||
if (url) fetch(url, { cache: 'force-cache' }).then(r => r.text()).then(svgText => {
|
||||
const realSvg = new DOMParser().parseFromString(svgText, "image/svg+xml").querySelector('svg');
|
||||
if (realSvg) {
|
||||
Array.from(imgNode.attributes).forEach(attr => realSvg.setAttribute(attr.name, attr.value));
|
||||
imgNode.replaceWith(realSvg);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (node._thisObj !== undefined) scanObj.thisObj = node._thisObj || null;
|
||||
else {
|
||||
let curr = node;
|
||||
while (curr && curr._thisObj === undefined) curr = curr.parentNode;
|
||||
scanObj.thisObj = curr ? curr._thisObj : null;
|
||||
}
|
||||
if (node._ref === undefined) {
|
||||
let curr = node;
|
||||
while (curr && curr._ref === undefined) curr = curr.parentNode;
|
||||
node._ref = curr ? { ...curr._ref } : {};
|
||||
}
|
||||
if (scanObj.extendVars) Object.assign(node._ref, scanObj.extendVars);
|
||||
|
||||
if (!scanObj.noBind) _parseNode(node, scanObj);
|
||||
|
||||
const nodes = [...(node.childNodes || []), ...(node.tagName === 'TEMPLATE' ? Array.from(node.content.childNodes).map(c => (c._stNoBind = true, c)) : [])];
|
||||
const nextScanObj = { thisObj: scanObj.thisObj, extendVars: { ...node._ref }, noBind: scanObj.noBind };
|
||||
nodes.forEach(child => {
|
||||
if (!child._stManaged) {
|
||||
const wasNoBind = nextScanObj.noBind;
|
||||
if (child._stNoBind) { nextScanObj.noBind = true; delete child._stNoBind; }
|
||||
_scanTree(child, nextScanObj);
|
||||
nextScanObj.noBind = wasNoBind;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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(mappings => {
|
||||
for (const [key, bindingSet] of mappings) {
|
||||
for (const binding of bindingSet) { if (binding.node === node) bindingSet.delete(binding); }
|
||||
}
|
||||
});
|
||||
node.childNodes && node.childNodes.forEach(child => _unbindTree(child));
|
||||
};
|
||||
|
||||
export const RefreshState = _scanTree;
|
||||
32
src/dom.js
32
src/dom.js
@ -28,7 +28,8 @@ if (typeof document !== 'undefined') {
|
||||
const originalSetAttribute = Element.prototype.setAttribute;
|
||||
Element.prototype.setAttribute = function (name, value) {
|
||||
if (!name.startsWith('$')) return originalSetAttribute.call(this, name, value);
|
||||
return originalSetAttribute.call(this, 'st-' + name.substring(1), value);
|
||||
const prefix = name.startsWith('$$') ? 'st-st-' : 'st-';
|
||||
return originalSetAttribute.call(this, prefix + name.substring(name.startsWith('$$') ? 2 : 1), value);
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -52,6 +53,11 @@ export function _updateBinding(binding) {
|
||||
if (window.__perfTrace) window.__perfTrace.evalCount++;
|
||||
const evalStart = window.__perfTrace ? performance.now() : 0;
|
||||
let result = binding.exp ? (binding.tpl ? _returnCode(binding.tpl, { thisNode: node }, node._thisObj || node, node._ref || null) : null) : binding.tpl;
|
||||
|
||||
if (binding.exp === 2 && typeof result === 'string') {
|
||||
try { result = _returnCode(result, { thisNode: node }, node._thisObj || node, node._ref || null); } catch (e) { }
|
||||
}
|
||||
|
||||
if (window.__perfTrace) window.__perfTrace.evalTotal += (performance.now() - evalStart);
|
||||
setActiveBinding(null);
|
||||
|
||||
@ -248,10 +254,11 @@ export const _parseNode = (node, scanObj) => {
|
||||
if (hasBindings) return;
|
||||
|
||||
let attrs = [];
|
||||
const triggerAttrs = ['$if', '$each', 'st-if', 'st-each', '$$if', '$$each', 'st-st-if', 'st-st-each'];
|
||||
if (node.tagName === 'TEMPLATE') {
|
||||
['$if', '$each', 'st-if', 'st-each'].forEach(n => node.hasAttribute(n) && attrs.push(node.getAttributeNode(n)));
|
||||
triggerAttrs.forEach(n => node.hasAttribute(n) && attrs.push(node.getAttributeNode(n)));
|
||||
} else {
|
||||
attrs = Array.from(node.attributes).filter(a => (a.name.startsWith('$') || a.name.startsWith('st-')) && !['$if', '$each', 'st-if', 'st-each'].includes(a.name) || a.name.includes('.'));
|
||||
attrs = Array.from(node.attributes).filter(a => (a.name.startsWith('$') || a.name.startsWith('st-')) && !triggerAttrs.includes(a.name) || a.name.includes('.'));
|
||||
}
|
||||
|
||||
if (node._thisObj && scanObj.thisObj) node._thisObj.parent = scanObj.thisObj;
|
||||
@ -260,8 +267,11 @@ export const _parseNode = (node, scanObj) => {
|
||||
node._states = 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;
|
||||
let exp = 0;
|
||||
if (attr.name.startsWith('$$') || attr.name.startsWith('st-st-')) exp = 2;
|
||||
else if (attr.name.startsWith('$') || attr.name.startsWith('st-')) exp = 1;
|
||||
|
||||
const realAttrName = exp === 2 ? (attr.name.startsWith('$$') ? attr.name.slice(2) : attr.name.slice(6)) : (exp === 1 ? (attr.name.startsWith('$') ? attr.name.slice(1) : attr.name.slice(3)) : attr.name);
|
||||
let tpl = attr.value;
|
||||
node.removeAttribute(attr.name);
|
||||
if (realAttrName.startsWith('.')) _initBinding({ node, prop: realAttrName.split('.'), tpl, exp });
|
||||
@ -324,9 +334,11 @@ export const _scanTree = (node, scanObj = {}) => {
|
||||
if (resolvedThisObj === undefined) resolvedThisObj = scanObj.thisObj;
|
||||
if (resolvedRef === undefined) resolvedRef = scanObj.extendVars;
|
||||
|
||||
if (node.tagName !== 'TEMPLATE' && (node.hasAttribute('$if') || node.hasAttribute('$each') || node.hasAttribute('st-if') || node.hasAttribute('st-each'))) {
|
||||
const triggerAttrs = ['$if', '$each', 'st-if', 'st-each', '$$if', '$$each', 'st-st-if', 'st-st-each'];
|
||||
const eachAttrs = ['$each', 'st-each', '$$each', 'st-st-each'];
|
||||
if (node.tagName !== 'TEMPLATE' && triggerAttrs.some(t => node.hasAttribute(t))) {
|
||||
const template = document.createElement('TEMPLATE');
|
||||
const attrs = Array.from(node.attributes).filter(attr => ['$if', '$each', 'st-if', 'st-each'].includes(attr.name) || ((node.hasAttribute('$each') || node.hasAttribute('st-each')) && ['as', 'index'].includes(attr.name)));
|
||||
const attrs = Array.from(node.attributes).filter(attr => triggerAttrs.includes(attr.name) || (eachAttrs.some(t => node.hasAttribute(t)) && ['as', 'index'].includes(attr.name)));
|
||||
attrs.forEach(attr => { template.setAttribute(attr.name, attr.value); node.removeAttribute(attr.name); });
|
||||
node.parentNode.insertBefore(template, node);
|
||||
template.content.appendChild(node);
|
||||
@ -335,13 +347,13 @@ export const _scanTree = (node, scanObj = {}) => {
|
||||
_scanTree(template, scanObj);
|
||||
return;
|
||||
}
|
||||
if (node.tagName === 'TEMPLATE' && (node.hasAttribute('$if') || node.hasAttribute('st-if')) && (node.hasAttribute('$each') || node.hasAttribute('st-each'))) {
|
||||
if (node.tagName === 'TEMPLATE' && (node.hasAttribute('$if') || node.hasAttribute('st-if') || node.hasAttribute('$$if') || node.hasAttribute('st-st-if')) && (node.hasAttribute('$each') || node.hasAttribute('st-each') || node.hasAttribute('$$each') || node.hasAttribute('st-st-each'))) {
|
||||
const template = document.createElement('TEMPLATE');
|
||||
const attrs = Array.from(node.attributes).filter(attr => ['$if', '$each', 'st-if', 'st-each'].includes(attr.name));
|
||||
const attrs = Array.from(node.attributes).filter(attr => triggerAttrs.includes(attr.name));
|
||||
const attr = attrs[attrs.length - 1];
|
||||
template.setAttribute(attr.name, attr.value);
|
||||
node.removeAttribute(attr.name);
|
||||
if (attr.name === '$each' || attr.name === 'st-each') {
|
||||
if (eachAttrs.includes(attr.name)) {
|
||||
Array.from(node.attributes).filter(attr => ['as', 'index'].includes(attr.name)).forEach(attr => { template.setAttribute(attr.name, attr.value); node.removeAttribute(attr.name); });
|
||||
}
|
||||
Array.from(node.content.childNodes).forEach(child => template.content.appendChild(child));
|
||||
|
||||
12
test-html-attr.cjs
Normal file
12
test-html-attr.cjs
Normal file
@ -0,0 +1,12 @@
|
||||
const { chromium } = require('@playwright/test');
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const name = await page.evaluate(() => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = '<div $.val="hello"></div>';
|
||||
return div.firstChild.attributes[0].name;
|
||||
});
|
||||
console.log('Attribute name:', name);
|
||||
await browser.close();
|
||||
})();
|
||||
@ -1,6 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"8a84b43f13b676ea22b7-5fcda25ae3a58304c071"
|
||||
]
|
||||
"status": "interrupted",
|
||||
"failedTests": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
// test/dom.test.js
|
||||
import { RefreshState, $, $$ } from '@web/state';
|
||||
import { NewState } from '@web/state';
|
||||
import { RefreshState, $, $$ } from '../src/index.js';
|
||||
import { NewState } from '../src/index.js';
|
||||
|
||||
export async function testDom() {
|
||||
console.log('Testing dom.js...');
|
||||
@ -65,15 +65,16 @@ export async function testDom() {
|
||||
if (items.length !== 1 || items[0].textContent !== 'C') throw new Error('$each removal failed');
|
||||
|
||||
// 4. Two-way binding (bind)
|
||||
console.log('Testing $bind...');
|
||||
document.body.innerHTML = `<input id="input-test" $bind="state.val">`;
|
||||
state.val = 'initial';
|
||||
window.state = NewState({ val: 'initial' });
|
||||
RefreshState(document.documentElement);
|
||||
await wait();
|
||||
const input = $('#input-test');
|
||||
if (input.value !== 'initial') throw new Error('$bind initial value failed');
|
||||
|
||||
input.value = 'changed';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
if (state.val !== 'changed') throw new Error('$bind write-back failed');
|
||||
|
||||
// 5. Unbinding cleanup (mock check)
|
||||
@ -83,7 +84,52 @@ export async function testDom() {
|
||||
state.show = true;
|
||||
const ifNode = $('#if-content');
|
||||
state.show = false; // Trigger _unbindTree via _clearRenderedNodes -> remove() -> MutationObserver (or manual in some cases)
|
||||
// Actually MutationObserver handles _unbindTree in index.js.
|
||||
|
||||
// 6. Double evaluation ($$ prefix)
|
||||
console.log('Testing double evaluation ($$)...');
|
||||
document.body.innerHTML = `
|
||||
<div id="double-eval-root">
|
||||
<div id="inner-node" $$if="state.innerExp">Dynamic Visible</div>
|
||||
</div>
|
||||
`;
|
||||
const doubleState = NewState({
|
||||
innerExp: 'state.innerShow',
|
||||
innerShow: false
|
||||
});
|
||||
window.state = doubleState;
|
||||
document.documentElement._thisObj = doubleState;
|
||||
|
||||
RefreshState(document.documentElement);
|
||||
await wait();
|
||||
if ($('#inner-node')) throw new Error('$$if failed: should be hidden initially');
|
||||
|
||||
console.log('Enabling inner node...');
|
||||
doubleState.innerShow = true;
|
||||
RefreshState(document.documentElement);
|
||||
await wait();
|
||||
const inner = $('#inner-node');
|
||||
if (!inner) throw new Error('$$if failed: should be visible after innerShow=true');
|
||||
console.log('Inner node visible:', inner.textContent);
|
||||
|
||||
// 7. Nested $$if
|
||||
console.log('Testing nested $$if...');
|
||||
document.body.innerHTML = `
|
||||
<div id="nested-double-test">
|
||||
<template $if="state.outer">
|
||||
<div id="nested-inner" $$if="state.innerExp">Nested Visible</div>
|
||||
</template>
|
||||
</div>
|
||||
`;
|
||||
doubleState.outer = true;
|
||||
doubleState.innerShow = false;
|
||||
RefreshState(document.documentElement);
|
||||
await wait();
|
||||
if ($('#nested-inner')) throw new Error('nested $$if failed: should be hidden initially');
|
||||
|
||||
doubleState.innerShow = true;
|
||||
RefreshState(document.documentElement);
|
||||
await wait();
|
||||
if (!$('#nested-inner')) throw new Error('nested $$if failed: should be visible after update');
|
||||
|
||||
console.log('dom.js tests passed');
|
||||
return true;
|
||||
|
||||
@ -34,10 +34,11 @@ export async function testPriority() {
|
||||
const state = { outerVal: 'hello' };
|
||||
const outer = document.createElement('div');
|
||||
outer.id = 'outer';
|
||||
outer._thisObj = state; // Rule: Set _thisObj on root before scanning if not in document
|
||||
outer.innerHTML = `<val-comp id="val-node" $.val="this.outerVal"></val-comp>`;
|
||||
document.body.appendChild(outer);
|
||||
|
||||
RefreshState(outer, { thisObj: state });
|
||||
document.body.appendChild(outer);
|
||||
|
||||
if (capturedVal !== 'hello') {
|
||||
throw new Error(`this. replacement failed: capturedVal was ${capturedVal}, expected 'hello'`);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user