站点:纯静态 HTML + CSS + 原生 JS,零框架零构建
页面:首页 / 合作品牌 / 荣誉资质 / 客户案例 / 关于我们
内容:
- site/ 站点本体(含已压缩 webp 图片,约 4.8 MB)
- deploy/ Docker Compose / Caddy / Apache 部署配置
- _tools/ 开发自检脚本与部署脚本
- Dockerfile 自建 nginx 镜像方案
- README.md 项目文档
设计:品牌黄 #ffc107 + 白,深蓝仅用于页脚;菜单长期置顶;
交互动效只加在关键词与区块级元素上。
素材说明:原始素材(背景图/老网站/方案/荣誉资质/合作品牌,约 290 MB)
未纳入版本控制,站点使用的是 site/assets 内已处理压缩的图片。
260 lines
9.6 KiB
JavaScript
260 lines
9.6 KiB
JavaScript
/* 最小 DOM 仿真(供 tools/test-js.js 使用)
|
|
* 支持:元素树、classList、属性、事件、innerHTML 解析、querySelector(All)
|
|
* 目标:足以在 Node 里跑通站点 main.js 的交互逻辑。
|
|
*/
|
|
'use strict';
|
|
|
|
class ClassList {
|
|
constructor(el) { this.el = el; this.set = new Set(); }
|
|
add(...c) { c.forEach(x => x && this.set.add(x)); this.sync(); }
|
|
remove(...c) { c.forEach(x => this.set.delete(x)); this.sync(); }
|
|
contains(c) { return this.set.has(c); }
|
|
toggle(c, force) {
|
|
const on = force === undefined ? !this.set.has(c) : !!force;
|
|
if (on) this.set.add(c); else this.set.delete(c);
|
|
this.sync();
|
|
return on;
|
|
}
|
|
sync() { this.el._class = [...this.set].join(' '); }
|
|
}
|
|
|
|
class El {
|
|
constructor(tag, doc) {
|
|
this.tagName = String(tag || 'div').toUpperCase();
|
|
this.children = [];
|
|
this.parentNode = null;
|
|
this.attrs = {};
|
|
this.style = {};
|
|
this._class = '';
|
|
this._text = '';
|
|
this._html = '';
|
|
this.listeners = {};
|
|
this.offsetWidth = 0;
|
|
this.scrollHeight = 120;
|
|
this._value = '';
|
|
this.complete = false;
|
|
this.naturalWidth = 100;
|
|
Object.defineProperty(this, 'complete', {
|
|
get() { return !!this._complete; },
|
|
set(v) {
|
|
this._complete = !!v;
|
|
if (v) setTimeout(() => this.dispatch('load'), 0);
|
|
}
|
|
});
|
|
Object.defineProperty(this, 'naturalWidth', {
|
|
get() { return this._nw !== undefined ? this._nw : (this._complete ? 100 : 0); },
|
|
set(v) { this._nw = v; }
|
|
});
|
|
Object.defineProperty(this, 'naturalHeight', {
|
|
get() { return this._nh !== undefined ? this._nh : (this._complete ? 100 : 0); },
|
|
set(v) { this._nh = v; }
|
|
});
|
|
this.classList = new ClassList(this);
|
|
this.ownerDocument = doc || null;
|
|
}
|
|
/* 属性/IDL 反射,贴近浏览器行为 */
|
|
get src() { return this.attrs.src || ''; }
|
|
set src(v) {
|
|
this.attrs.src = String(v);
|
|
if (this.tagName === 'IMG') this.complete = true;
|
|
}
|
|
get href() { return this.attrs.href || ''; }
|
|
set href(v) { this.attrs.href = String(v); }
|
|
get id() { return this.attrs.id || ''; }
|
|
set id(v) { this.attrs.id = String(v); }
|
|
get type() { return this.attrs.type || ''; }
|
|
set type(v) { this.attrs.type = String(v); }
|
|
get name() { return this.attrs.name || ''; }
|
|
set name(v) { this.attrs.name = String(v); }
|
|
get value() { return this._value; }
|
|
set value(v) { this._value = String(v); this.attrs.value = String(v); }
|
|
get className() { return this._class; }
|
|
set className(v) { this._class = String(v); this.classList.set = new Set(this._class.split(/\s+/).filter(Boolean)); }
|
|
get textContent() { return this._text || this.children.map(c => c.textContent).join(''); }
|
|
set textContent(v) { this._text = String(v); this.children = []; }
|
|
get innerHTML() { return this._html; }
|
|
set innerHTML(v) { this._html = String(v); parseInto(this, this._html); }
|
|
get outerHTML() { return this._html; }
|
|
set outerHTML(v) { this._html = String(v); this._replaced = true; if (this.parentNode) this.parentNode._replacedChildren = true; }
|
|
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
|
|
cloneNode(deep) {
|
|
const copy = new El(this.tagName, this.ownerDocument);
|
|
copy.attrs = Object.assign({}, this.attrs);
|
|
copy._class = this._class;
|
|
copy.classList.set = new Set(this.classList.set);
|
|
copy._text = this._text;
|
|
copy._html = this._html;
|
|
copy._value = this._value;
|
|
if (deep) this.children.forEach(c => copy.appendChild(c.cloneNode(true)));
|
|
return copy;
|
|
}
|
|
removeChild(c) { this.children = this.children.filter(x => x !== c); return c; }
|
|
setAttribute(k, v) { this.attrs[k] = String(v); }
|
|
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
|
|
removeAttribute(k) { delete this.attrs[k]; }
|
|
hasAttribute(k) { return k in this.attrs; }
|
|
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); }
|
|
removeEventListener(t, fn) {
|
|
if (this.listeners[t]) this.listeners[t] = this.listeners[t].filter(f => f !== fn);
|
|
}
|
|
dispatch(t, ev) {
|
|
const evt = Object.assign({
|
|
type: t, target: this, preventDefault() { }, stopPropagation() { }
|
|
}, ev || {});
|
|
(this.listeners[t] || []).slice().forEach(fn => fn(evt));
|
|
if (this.parentNode && !evt._stopped) this.parentNode.dispatch(t, Object.assign(evt, { target: this }));
|
|
return evt;
|
|
}
|
|
closest(sel) {
|
|
let n = this;
|
|
while (n) { if (n.matches(sel)) return n; n = n.parentNode; }
|
|
return null;
|
|
}
|
|
focus() { this.ownerDocument && (this.ownerDocument.activeElement = this); }
|
|
scrollIntoView() { }
|
|
reset() {
|
|
(function walk(node) {
|
|
node.children.forEach(c => {
|
|
if (c.tagName === 'INPUT' || c.tagName === 'TEXTAREA' || c.tagName === 'SELECT') c.value = '';
|
|
walk(c);
|
|
});
|
|
})(this);
|
|
}
|
|
getBoundingClientRect() {
|
|
var w = this._boxW || 100, h = this._boxH || 100;
|
|
return { top: 0, left: 0, right: w, bottom: h, width: w, height: h };
|
|
}
|
|
matches(sel) { return matchCompound(this, sel); }
|
|
querySelector(sel) { return queryAll(this, sel)[0] || null; }
|
|
querySelectorAll(sel) { return queryAll(this, sel); }
|
|
get firstChild() { return this.children[0] || null; }
|
|
get nodeType() { return 1; }
|
|
}
|
|
|
|
/* ---------------------------------------------------------- 选择器 */
|
|
function matchSimple(el, sel) {
|
|
sel = sel.trim();
|
|
if (!sel) return true;
|
|
const parts = sel.match(/(^[a-zA-Z][\w-]*)|(\.[\w-]+)|(#[\[\]\w-]+)|(\[[^\]]+\])/g) || [];
|
|
for (const p of parts) {
|
|
if (p.startsWith('.')) { if (!el.classList.contains(p.slice(1))) return false; }
|
|
else if (p.startsWith('#')) { if (el.attrs.id !== p.slice(1)) return false; }
|
|
else if (p.startsWith('[')) {
|
|
const m = /^\[([\w-]+)(?:\s*([~|^$*]?=)\s*"?([^"\]]*)"?)?\]$/.exec(p);
|
|
if (!m) return false;
|
|
const [, name, op, val] = m;
|
|
if (!(name in el.attrs)) return false;
|
|
if (!op) continue;
|
|
const have = String(el.attrs[name]);
|
|
if (op === '=') { if (have !== val) return false; }
|
|
else if (op === '~=') { if (!have.split(/\s+/).includes(val)) return false; }
|
|
else if (op === '*=') { if (have.indexOf(val) < 0) return false; }
|
|
else if (op === '^=') { if (have.indexOf(val) !== 0) return false; }
|
|
else if (op === '$=') { if (!have.endsWith(val)) return false; }
|
|
} else if (el.tagName !== p.toUpperCase()) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function splitDescendant(sel) {
|
|
const out = [];
|
|
let depth = 0, cur = '';
|
|
for (const ch of sel) {
|
|
if (ch === '[') depth++;
|
|
if (ch === ']') depth--;
|
|
if (/\s/.test(ch) && depth === 0) { if (cur) { out.push(cur); cur = ''; } }
|
|
else cur += ch;
|
|
}
|
|
if (cur) out.push(cur);
|
|
return out;
|
|
}
|
|
|
|
function matchCompound(el, sel) {
|
|
for (const group of sel.split(',')) {
|
|
const parts = splitDescendant(group);
|
|
if (!parts.length) continue;
|
|
if (!matchSimple(el, parts[parts.length - 1])) continue;
|
|
let i = parts.length - 2, n = el.parentNode, ok = true;
|
|
while (i >= 0) {
|
|
if (!n) { ok = false; break; }
|
|
if (matchSimple(n, parts[i])) i--;
|
|
n = n.parentNode;
|
|
}
|
|
if (ok && i < 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function queryAll(root, selector) {
|
|
const out = [];
|
|
(function walk(node) {
|
|
for (const c of node.children) {
|
|
if (matchCompound(c, selector)) out.push(c);
|
|
walk(c);
|
|
}
|
|
})(root);
|
|
return out;
|
|
}
|
|
|
|
/* ---------------------------------------------------------- innerHTML 解析 */
|
|
const VOID = new Set(['img', 'br', 'hr', 'input', 'meta', 'link', 'source', 'area', 'base', 'col']);
|
|
|
|
function parseInto(root, html) {
|
|
root.children = [];
|
|
const stack = [root];
|
|
const re = /<!--[\s\S]*?-->|<\/([a-zA-Z][\w-]*)\s*>|<([a-zA-Z][\w-]*)((?:\s+[^<>]*?)?)\/?>|([^<]+)/g;
|
|
let m;
|
|
while ((m = re.exec(html))) {
|
|
if (m[0].startsWith('<!--')) continue;
|
|
if (m[1]) { // 闭合标签
|
|
const tag = m[1].toUpperCase();
|
|
for (let i = stack.length - 1; i > 0; i--) {
|
|
if (stack[i].tagName === tag) { stack.length = i; break; }
|
|
}
|
|
continue;
|
|
}
|
|
if (m[2]) { // 开标签
|
|
const tag = m[2];
|
|
const node = new El(tag, root.ownerDocument);
|
|
const attrStr = m[3] || '';
|
|
const ar = /([\w:-]+)(?:\s*=\s*"([^"]*)"|\s*=\s*'([^']*)'|\s*=\s*([^\s"'>]+))?/g;
|
|
let a;
|
|
while ((a = ar.exec(attrStr))) {
|
|
const name = a[1];
|
|
const val = a[2] !== undefined ? a[2] : a[3] !== undefined ? a[3] : a[4] !== undefined ? a[4] : '';
|
|
if (name === 'class') node.className = val;
|
|
else node.attrs[name] = val;
|
|
}
|
|
stack[stack.length - 1].appendChild(node);
|
|
if (!VOID.has(tag.toLowerCase()) && !m[0].endsWith('/>')) stack.push(node);
|
|
continue;
|
|
}
|
|
if (m[4]) { // 文本
|
|
const t = m[4];
|
|
if (t.trim()) stack[stack.length - 1]._text += t;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ---------------------------------------------------------- document */
|
|
function createDocument() {
|
|
const doc = new El('html', null);
|
|
doc.ownerDocument = doc;
|
|
doc.documentElement = doc;
|
|
doc.body = doc.appendChild(new El('body', doc));
|
|
doc.readyState = 'complete';
|
|
doc.hidden = false;
|
|
doc._l = {};
|
|
doc.activeElement = doc.body;
|
|
doc.createElement = t => new El(t, doc);
|
|
doc.addEventListener = El.prototype.addEventListener.bind(doc);
|
|
doc.removeEventListener = El.prototype.removeEventListener.bind(doc);
|
|
doc.dispatch = El.prototype.dispatch.bind(doc);
|
|
doc.querySelector = sel => queryAll(doc, sel)[0] || null;
|
|
doc.querySelectorAll = sel => queryAll(doc, sel);
|
|
doc.getElementById = id => queryAll(doc, '#' + id)[0] || null;
|
|
return doc;
|
|
}
|
|
|
|
module.exports = { El, createDocument, queryAll, matchCompound };
|