探索 Proxy 实现 DOM Intellisense,使用 TypeScript 进行元素绑定、双向数据绑定、事件等





5.00/5 (6投票s)
了解 Proxy 类如何用于消除元素 ID 字符串字面量,并与 TypeScript 结合,为 HTML 元素提供 Intellisense
目录
引言
我讨厌前端开发的两件事
- 元素 ID 是字符串字面量
- HTML 中的 JavaScript 代码
- JavaScript
- 实际上,任何与前端开发相关的事情,但这就是生活
哦,等等!那是四件事。
当我开始为这篇文章编写代码时,我最终经历了一些 U 理论 所描述的“从正在显现的未来中领导”的体验。没错。尽管如此,这就是我的经历。
所以我的“未来”是发现我将在这里展示的代码,嗯,不是我现在真正想用的,因为未来已经到来,当我写完这篇文章时,我意识到有很多事情我会做得不同!无论如何,我发现这是一个有用的探索,探讨如何利用 Proxy 类型将类属性绑定到模型,实现双向绑定,订阅 UI 事件等等,所有这些都使用实际的“编辑时”类型以实现类型安全和 Intellisense 支持——也就是说,没有字符串通过 ID 引用 DOM 元素。因此“IX”诞生了,它是“Interacx”的缩写,这是一个我很久以前创建的 WinForm 工具套件,用于自动化数据操作而无需使用 ORM。我决定重新利用这个名字,因为 WinForm 应用程序,嗯,过时了,现实是,我讨厌的,编写 Web 应用程序,才是真正的应用程序所在,我是说,重点。为了您的无尽乐趣,我决定使用一些 Vue 示例与我在这里使用代理开发的实现进行比较。
优点
使用我在这里开发的代码,我发现了一些优点:
- 我没有硬编码 DOM ID 字符串字面量。
- 我能够利用 TypeScript 的类型安全性。
- 能够将 DOM 元素作为对象属性引用,可以利用 Visual Studio 的 Intellisense。
- 连接事件和绑定非常容易。
- 编写单元测试非常容易——事实上,单元测试在我看来是这段代码中最有趣的方面之一。
- 我没有在 HTML 中放置“声明性代码”
- HTML 保持完全干净。
- 业务逻辑在代码中实现。
- 你不需要同时检查代码和 HTML 来弄清楚到底发生了什么。
- 第 6 点
- 第 6 点
- 第 6 点
我无法足够强调第 6 点的重要性,至少对我而言。对于大型 Web 应用程序,我曾因在代码和标记之间来回跳转以找出条件、循环和渲染而绞尽脑汁,这是一种令人沮丧的体验。对我来说,在 UI 级别包含由业务数据/规则驱动的声明性语法是一个糟糕的,不,是可怕的设计。这就是我不用 Razor 或类似渲染引擎的原因。我个人认为,HTML 中那些神秘的自定义标签,“if”和“loop”标签等,用于控制 UI 渲染,是所谓现代 Web 开发中最糟糕的想法之一。
缺点
所以让我们现实一点
- 语法要求 DOM 元素 ID 和对象属性名称之间有特定的映射。
- 代理更慢。
- 使用代理的代码高度专业化。
- 处理数组的代码很奇怪。
- 这里的代码在处理所有可能的 DOM 属性、特性和事件方面非常不完整。
- 我不知道这里的代码是否足够健壮以处理第 4 点。
- 我还没有探索这个概念是否能很好地与第三方小部件库配合使用,我最喜欢的是 jqWidgets。
- “未来”来得相当晚,基本上是我写完这篇文章的时候。
而且我真的怀疑有人会说:“哦,我们用 IX 来构建一个大型网站吧”,除了我可能!
那么为什么要费心呢?
- 我喜欢探索不同的方法来解决 Web 开发的缺点。
- 我没有遇到其他人尝试过这种方法。
- 学习代理非常有趣。
- 这很有趣!
什么是 Proxy?
至少在 JavaScript 中,`Proxy` 是一个替换你的对象并允许你拦截“`get`”和“`set`”方法的对象。在此处阅读更多关于 `Proxy` 对象的信息:这里。
一个简单示例
一个简单的演示就足够了。首先,一个简单的代理存根,它只执行 `get`/`set` 操作并进行控制台日志记录
private myProxyHandler = {
get: (obj, prop) => {
console.log(`get ${prop}`);
return obj[prop];
},
set: (obj, prop, val) => {
console.log(`set ${prop} to ${val}`);
obj[prop] = val;
// Return true to accept change.
return true;
}
}
一个简单的测试用例
let proxy = new Proxy({}, this.myProxyHandler);
proxy.foo = 1;
let foo = proxy.foo;
console.log(`foo = ${foo}`);
以及输出
set foo to 1
get foo
foo = 1
啊哈,感受力量吧!世界现在是我的了!
一个 DOM 示例
现在让我们做一些更有趣的事情。我们将创建一个类,其中包含一个属性,其名称与 DOM 元素匹配。DOM 元素,带有一个标签,因为输入元素应该有标签
<div class="inline marginTop5">
<div class="inline label">Name:</div>
<div class="inline"><input id="name"/></div>
</div>
激动人心!
现在是这个类
class NameContainer {
name: string;
}
以及新的代理
private valueProxy = {
get: (obj, prop) => {
console.log(`get ${prop}`);
return obj[prop];
},
set: (obj, prop, val) => {
console.log(`set ${prop} to ${val}`);
let el = document.getElementById(prop) as HTMLInputElement;
el.value = val;
obj[prop] = val;
// Return true to accept change.
return true;
}
}
注意,我只添加了这些
let el = document.getElementById(prop) as HTMLInputElement;
el.value = val;
这里,假设属性名就是元素 ID!
现在我们可以设置值,它会代理设置对象的 `name` 属性和 DOM `value` 属性
let nc = new Proxy(new NameContainer(), this.valueProxy);
nc.name = "Hello World!";
结果是:
如果我输入了一些东西,并且我想在“获取”`name` 属性时看到该值怎么办?很简单,getter 变为这样
get: (obj, prop) => {
console.log(`get ${prop}`);
let el = document.getElementById(prop) as HTMLInputElement;
let val = el.value;
obj[prop] = val;
return obj[prop];
},
我们可以通过模拟用户所做的更改来测试代码
let nc = new Proxy(new NameContainer(), this.valueProxy);
nc.name = "Hello World!";
// Simulate the user having changed the input box:
let el = document.getElementById("name") as HTMLInputElement;
el.value = "fizbin";
let newName = nc.name;
console.log(`The new name is: ${newName}`);
在控制台日志中,我们看到
set name to Hello World!
get name
The new name is: fizbin
需要注意的是,`obj[prop] = val` 会对非代理对象进行赋值,因此代理的 *setter* 不会被调用。
重点是...
- 我正在使用类型(因此也使用 Intellisense)来获取/设置 DOM 元素值。
- 我通过假设类属性的名称与元素 ID 相同,消除了名称的字符串字面量。
时髦!这是 Marc 的一小步,却是更好前端开发的一大步!不幸的是,登月需要更多的努力、基础设施、时间和一路上的许多灾难(这里停顿一下,以纪念在太空探索中失去的生命,我不想对“灾难”显得轻浮。)
那么,让我们开始沿着 U 型曲线下降的旅程吧!
关于代码
代码是 TypeScript,带有简单的 HTML 和 CSS,在 VS2017 解决方案中实现。
源代码也可以在 https://github.com/cliftonm/IX 找到。
有两个 HTML 页面可供玩耍
- index.html 是演示页面。
- Tests/IntegrationTests.html 运行集成测试。
Inner HTML 的简单数据绑定
我们从与 `DIV` 关联的 inner HTML 的简单数据绑定开始。
Vue 的方式
<div id="app">
{{ message }}
</div>
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
我不喜欢什么
- “Mustache”`{{ }}` 的用法。
- 那个 `#app`。
- 整个 `data` 对象的东西。
IX 的方式
<div id="app"></div>
let form = IX.CreateNullProxy(); // No associated view model.
form.app = "Hello Interacx!";
就是这样!
响应式行为
下一个例子是显示一些实时计算值作为 `SPAN` 标题的一部分。
Vue 的方式
<div id="app-2">
<span v-bind:title="message">
Hover your mouse over me for a few seconds
to see my dynamically bound title!
</span>
</div>
var app2 = new Vue({
el: '#app-2',
data: {
message: 'You loaded this page on ' + new Date().toLocaleString()
}
})
IX 的方式
<span id="mySpan">However your mouse over me for a few seconds
to see the dynamically bound title!</span>
class HoverExample {
mySpan = {
attr: { title: "" }
};
onMySpanHover = new IXEvent();
}
let form = IX.CreateProxy(new HoverExample());
form
.onMySpanHover
.Add(() =>
hform.mySpan.attr.title = `You loaded this page on ${new Date().toLocaleString()}`);
更冗长,但好处是你正在使用可重复的多播事件处理程序模式。我确实有一个实现,可以直接将标题设置为函数,但我并不喜欢这种实现所需的幕后一次性实现。
条件语句
我也不喜欢用声明性代码元素弄乱标记。
Vue 的方式
<div id="app-3">
<span v-if="seen">Now you see me</span>
</div>
var app3 = new Vue({
el: '#app-3',
data: {
seen: true
}
})
IX 的方式
在 IX 中,条件行为通过事件机制实现,通常用于操作元素属性。与上面的 Vue 示例略有不同,请注意添加了两个按钮来切换 `SPAN` 的可见性
<span id="seen">Now you see me...</span>
<!-- Two ways to declare a button -->
<button id="show">Show</button>
<input id="hide" type="button" value="Hide" />
class VisibilityExample {
seen = {
attr: { visible: true }
};
onShowClicked = new IXEvent().Add((_, p) => p.seen.attr.visible = true);
onHideClicked = new IXEvent().Add((_, p) => p.seen.attr.visible = false);
}
IX.CreateProxy(new VisibilityExample());
这些连接到两个按钮,因此有事件处理程序。
这里
- 我们有一致的方式来操作元素属性。
- Intellisense 在 Visual Studio 中完美运行。
- 没有“`string`”元素名称。
循环
Vue 的方式
<div id="app-4">
<ol>
<li v-for="todo in todos">
{{ todo.text }}
</li>
</ol>
</div>
var app4 = new Vue({
el: '#app-4',
data: {
todos: [
{ text: 'Learn JavaScript' },
{ text: 'Learn Vue' },
{ text: 'Build something awesome' }
]
}
})
IX 的方式
<ol id="someList"></ol>
class ListExample {
someList: string[] = ["Learn Javascript", "Learn IX", "Wear a mask!"];
}
IX.CreateProxy(new ListExample());
结果
鉴于大多数列表来自数据源而不是硬编码
<ol id="someList"></ol>
class ListExample {
someList: string[] = [];
}
let listForm = IX.CreateProxy(new ListExample());
listForm.someList.push("Learn Javascript");
listForm.someList.push("Learn IX");
listForm.someList.push("Wear a mask!");
或者
let listForm = IX.CreateProxy(new ListExample());
let items = ["Learn Javascript", "Learn IX", "Wear a mask!"];
listForm.someList = items;
按钮点击
Vue 的方式
<div id="app-5">
<p>{{ message }}</p>
<button v-on:click="reverseMessage">Reverse Message</button>
</div>
var app5 = new Vue({
el: '#app-5',
data: {
message: 'Hello Vue.js!'
},
methods: {
reverseMessage: function () {
this.message = this.message.split('').reverse().join('')
}
}
})
IX 的方式
<div>
<p id="message"></p>
<button id="reverseMessage">Reverse Message</button>
</div>
class ReverseExample {
message = "Hello From Interacx!";
onReverseMessageClicked = new IXEvent()
.Add((_, p: ReverseExample) => p.message = p.message.split('').reverse().join(''));
}
IX.CreateProxy(new ReverseExample());
再次注意
- 无需“`Mustache`”`{{ }}` 语法。
- 没有“`#id`”`string` 来标识元素 ID。
- 事件机制是多播的,允许我们连接多个事件(未图示,但这是使用事件的重点。)
点击按钮后
数据转换
以下示例类似于 Vue 的 `.number` 属性,但实际实现更通用。
考虑这个 UI
以及标记(为便于阅读,删除了 CSS 和多余的 DIV)
X:
<input id="x" class="fieldInputSmall" />
Y:
<input id="y" class="fieldInputSmall" />
这里,我们不希望字符串“`1`”和“`2`”相加得到“`12`”,所以我们实现转换器
class InputForm {
x: number;
y: number;
onXChanged = new IXEvent();
onYChanged = new IXEvent();
// Converters, so 1 + 2 != '12'
onConvertX = x => Number(x);
onConvertY = y => Number(y);
Add = () => this.x + this.y;
}
class OutputForm {
sum: number;
}
事件像这样连接
let inputForm = IX.CreateProxy(new InputForm());
let outputForm = IX.CreateProxy(new OutputForm());
inputForm.onXChanged.Add(() => outputForm.sum = inputForm.Add());
inputForm.onYChanged.Add(() => outputForm.sum = inputForm.Add());
在幕后,输入框文本通过 `onConvertX` 和 `onConvertY` 转换器转换为 `Number`,其余部分由属性的标准数据绑定处理,将 `sum` 设置为 `x` 和 `y` 的值。
另外,请注意如何创建类作为 HTML 片段的容器。我们可以很容易地将 `sum` 放入 `InputForm` 中,但我希望通过使用单独的容器对象 `OutputForm` 来演示如何将属性 compartmentalize 到单独的容器中。
双向绑定
我们已经在上面的示例中看到了视图和模型之间的绑定。Vue 的一个示例是根据输入元素的实时更新直接更新一个元素。虽然我想不出一个实际生活中需要这样做的例子,但实时更新(例如筛选条件)肯定是有用的,所以我们从 Vue 示例开始
Vue 的方式
<div id="app-6">
<p>{{ message }}</p>
<input v-model="message">
</div>
var app6 = new Vue({
el: '#app-6',
data: {
message: 'Hello Vue!'
}
})
IX 的方式
这已经很容易通过事件实现
First Name:
<p id="message2">/p>
<input id="input2"/>
class BidirectionalExample {
message2: string = "";
input2: string = "";
onInput2KeyUp = new IXEvent().Add((v, p: BidirectionalExample) => p.message2 = v);
}
IX.CreateProxy(new BidirectionalExample());
然而,为了使其更“Vue 化”,我们可以这样做
class BidirectionalExample {
message2 = new IXBinder({ input2: null });
input2: string = "";
这里,我们将“`from`”元素指定为键,以及键的任何“`value`”。令我不悦的是,键无法以利用 Intellisense 和类型检查的方式实现。我们能做的最好的事情是运行时检查“`from`”绑定元素是否存在。因此,在这一点上,将“`bind from`”属性指定为 `string` 几乎说得通。相反,我选择了这种实现方式
class BidirectionalExample {
input2: string = "";
message2 = new IXBinder({ bindFrom: IX.nameof(() => this.input2) });
onInput2KeyUp = new IXEvent().Add((v, p: BidirectionalExample) => p.message2 = v);
}
这也有点糟糕,但它具有支持 Intellisense 的优势,尽管你绑定的属性必须已经事先声明。在幕后,我们有一个非常简单的实现来提取名称,通过将函数转换为 `string`
public static nameof<TResult>(name: () => TResult): string {
let ret = IX.RightOf(name.toString(), ".");
return ret;
}
遗憾的是,除非你想使用像 ts-nameof 这样的东西,否则 JavaScript 能做的最好也就是这样了,我不想用它,因为 `ts-nameof` 是一个编译时转换,我不想让使用这个库的开发者为了让它工作而费尽周折。
我们还可以将同一个源绑定到不同的目标
<p>
<label id="message2"></label>
<label id="message3"></label>
</p>
<input id="input2" />
class BidirectionalExample {
input2: string = "";
message2 = new IXBinder({ bindFrom: IX.nameof(() => this.input2) });
message3 = new IXBinder({ bindFrom: IX.nameof(() => this.input2) });
}
以及不同的源到相同的目标
<p>
<label id="message2"></label>
<label id="message3"></label>
</p>
<input id="input2" />
<input id="input3" />
class BidirectionalExample {
input2: string = "";
input3: string = "";
message2 = new IXBinder({ bindFrom: IX.nameof(() => this.input2) });
message3 = new IXBinder({ bindFrom: IX.nameof(() => this.input2) })
.Add({ bindFrom: IX.nameof(() => this.input3) });
}
这里,在左侧编辑框中输入会设置消息 2 和 3
在右侧编辑框中输入会设置消息 3
但正如我之前所说,进行这种绑定并没有多大意义。通常,转换会做一些“有用”的事情,所以我们有这个设计好的例子
class BidirectionalExample {
input2: string = "";
input3: string = "";
message2 = new IXBinder({ bindFrom: IX.nameof(() => this.input2) });
message3 = new IXBinder({ bindFrom: IX.nameof(() => this.input2) }).Add({
bindFrom: IX.nameof(() => this.input3),
op: v => v.split('').reverse().join('')
});
// onInput2KeyUp = new IXEvent().Add((v, p: BidirectionalExample) => p.message2 = v);
}
于是我们得到
复选框
绑定复选框状态
Vue 的方式
Vue 有一个优雅的演示,将 `checkbox` 状态绑定到标签
<input type="checkbox" id="checkbox" v-model="checked">
<label for="checkbox">{{ checked }}</label>
IX 的方式
鉴于
<input id="checkbox" type="checkbox" />
<label id="ckLabel" for="checkbox"></label>
我们继续遵循使用 TypeScript 类和属性的模式
class CheckboxExample {
checkbox: boolean = false;
ckLabel = new IXBinder({ bindFrom: IX.nameof(() => this.checkbox) });
}
IX.CreateProxy(new CheckboxExample());
或者,因为上面的 `nameof` 语法很笨拙,而且代码转译后 JavaScript 中没有真正的“`nameof`”运算符,所以在这种情况下我们不得不回归到字符串字面量
class CheckboxExample {
checkbox: boolean = false;
ckLabel = new IXBinder({ bindFrom: "checkbox" });
}
IX.CreateProxy(new CheckboxExample());
或者我们可以连接 `click` 事件
class CheckboxExample {
checkbox: boolean = false;
ckLabel: string = "Unchecked";
onCheckboxClicked =
new IXEvent().Add(
(_, p: CheckboxExample) =>
p.ckLabel = p.checkbox ? "Checked" : "Unchecked");
}
IX.CreateProxy(new CheckboxExample());
绑定复选框值
View 的方式
<input type="checkbox" id="jack" value="Jack" v-model="checkedNames">
<label for="jack">Jack</label>
<input type="checkbox" id="john" value="John" v-model="checkedNames">
<label for="john">John</label>
<input type="checkbox" id="mike" value="Mike" v-model="checkedNames">
<label for="mike">Mike</label>
<br>
<span>Checked names: {{ checkedNames }}</span>
请注意,`span` 文本包含方括号
IX 的方式
鉴于
<input id="jane" value="Jane" type="checkbox" />
<label for="jane">Jane</label>
<input id="mary" value="Mary" type="checkbox" />
<label for="mary">Mary</label>
<input id="grace" value="Grace" type="checkbox" />
<label for="grace">Grace</label>
<br />
<label id="ckNames"></label>
我们使用特殊的数组绑定来实现容器对象(因为属性在类中不存在,我不能使用“`nameof`”笨拙的方法,所以“`ID`”遗憾地是字符串字面量。)当然,在下一个示例中,我确实有复选框的属性,但我仍然使用了字符串字面量!
class CheckboxListExample {
ckNames = IXBinder.AsArray(items => items.join(", "))
.Add({ bindFrom: "jane", attribute: "value" })
.Add({ bindFrom: "mary", attribute: "value" })
.Add({ bindFrom: "grace", attribute: "value" });
}
IX.CreateProxy(new CheckboxListExample());
然后我们得到
请注意,我们没有用复选框状态初始化属性!如果我们这样做
class CheckboxListExample {
jane: boolean = false;
mary: boolean = false;
grace: boolean = false;
ckNames = IXBinder.AsArray(items => items.join(", "))
.Add({ bindFrom: "jane", attribute: "value" })
.Add({ bindFrom: "mary", attribute: "value" })
.Add({ bindFrom: "grace", attribute: "value" });
}
let ckListExample = IX.CreateProxy(new CheckboxListExample());
我们可以通过编程方式设置检查状态
ckListExample.jane = true;
ckListExample.mary = true;
我们看到
因此,我们这里注意到的一件事是,引用 HTML 元素的属性与*元素的 checked 属性相关联*。这是 IX 编码方式的一个产物,实际上指出了一个有趣的问题——对象属性只映射到 DOM 元素的一个属性,而且 IX 对该 DOM 元素应该是什么,取决于元素是什么,非常有主见!
单选按钮
View 的方式
此示例将 `radio` 按钮的值绑定到 `span`
<input type="radio" id="one" value="One" v-model="picked">
<label for="one">One</label>
<br>
<input type="radio" id="two" value="Two" v-model="picked">
<label for="two">Two</label>
<br>
<span>Picked: {{ picked }}</span>
IX 的方式
鉴于
<input id="marc" value="Marc" type="radio" name="group1" />
<label for="marc">Marc</label>
<input id="chris" value="Chris" type="radio" name="group1" />
<label for="chris">Chris</label>
<br />
<label id="rbPicked"></label>
我们添加了两个绑定器,无论点击哪个,其绑定器事件都会触发。再次注意,在这个例子中,我没有使用“`nameof`”语法,因为在这种情况下属性不存在!
class RadioExample {
rbPicked = new IXBinder({ bindFrom: "marc", attribute: "value" })
.Add({ bindFrom: "chris", attribute: "value" });
}
IX.CreateProxy(new RadioExample());
从而更新到当前选中的单选按钮
如果我们想通过编程方式设置单选按钮的状态,请定义属性
class RadioExample {
marc: boolean = false;
chris: boolean = false;
rbPicked = new IXBinder({ bindFrom: "marc", attribute: "value" })
.Add({ bindFrom: "chris", attribute: "value" });
}
在代理初始化后,设置状态
let rbExample = IX.CreateProxy(new RadioExample());
rbExample.chris = true;
组合框
Vue 的方式
<select v-model="selected">
<option disabled value="">Please select one</option>
<option>A</option>
<option>B</option>
<option>C</option>
</select>
<span>Selected: {{ selected }}</span>
IX 的方式
鉴于
<select id="selector">
<option selected disabled>Please select one</option>
<option value="1">A</option>
<option value="2">B</option>
<option value="3">C</option>
</select>
<br />
<span id="selection"></span>
以及容器类
class ComboboxExample {
selector = new IXSelector();
selection: string = "";
onSelectorChanged =
new IXEvent().Add((_, p) =>
p.selection = `Selected: ${p.selector.text} with value ${p.selector.value}`);
}
IX.CreateProxy(new ComboboxExample());
我们接着看到
选择后
请注意,作为 `IXSelector` 实现的 `selector` 属性,包含两个属性:`text` 和 `value`,用于选定的项目。
我们还可以通过编程方式初始化选项。给定
<select id="selector2"></select>
<br />
<span id="selection2"></span>
和
class ComboboxInitializationExample {
selector2 = new IXSelector().Add
({ selected:true, disabled: true, text: "Please select one" })
.Add({ value: 12, text: "AAA" })
.Add({ value: 23, text: "BBB" })
.Add({ value: 34, text: "CCC" });
selection2: string = "";
onSelector2Changed = new IXEvent().Add((_, p) =>
p.selection2 = `Selected: ${p.selector2.text} with value ${p.selector2.value}`);
}
let cb = IX.CreateProxy(new ComboboxInitializationExample());
我们看到
并使用选项值以编程方式设置选择
cb.selector2.value = 34;
或使用选项文本
cb.selector2.text = "AAA";
<img border="0" height="45" src="5272881/select5.png" width="182" />
或添加到选项列表
cb.selector2.options.push({ text: "DDD", value: 45 });
或删除选项项
cb.selector2.options.pop();
或更改选项的文本和值
cb.selector2.options[2] = { text: "bbb", value: 999 };
实现模式
IX 要求类属性与 DOM 元素 ID 匹配,并且事件处理程序具有特定的签名。
ID 和 Class 属性名称
KeyUp、Changed 和 Convert 事件
Notice:
事件名称使用属性名,首字母大写,所以 `firstName` 变为 `FirstName`。
支持的事件
KeyUp
`on[Prop]KeyUp` - 实时按键弹起事件
Changed
`on[Prop]Changed` - 元素失去焦点
此事件适用于文本、单选和复选框输入以及“`select`”(`combobox`)元素。
转换
`onConvert[Prop]` - 如果已定义,则在 `KeyUp` 和 `Changed` 事件触发之前执行该函数。
悬停
`on[Prop]Hover` - 如果已定义且属性具有签名
{
attr: { title: "" }
};
这将鼠标悬停时设置元素的标题。
集成测试
我们可以通过在模型更改后直接检查 DOM 元素来轻松测试 IX 的行为,反之亦然。我更喜欢使用“集成测试”而不是“单元测试”这个词,因为我们不是在测试 IX 库中的低级函数——我们正在测试 DOM 元素与对象属性的集成。
测试用例的 HTML 很简单
<div id="testResults" class="inline" style="min-width:600px">
<ol id="tests"></ol>
</div>
<div id="testDom"></div>
我们有一个有序列表用于测试结果,以及一个 `div` 用于放置每个测试所需的 HTML。
测试运行器
这些测试实际上使用 IX 来操作测试结果,并直接操作 DOM 以模拟 UI 更改。运行器看起来像这样
let testForm = IX.CreateProxy(new TestResults());
let idx = 0;
tests.forEach(test => {
// Get just the name of the test function.
let testName = IX.LeftOf(test.testFnc.toString(), "(");
// The ID will start with a lowercase letter
let id = IX.LowerCaseFirstChar(testName);
// Push a template to OL, where the template value is simply the test name,
// to the test results ordered list.
testForm.tests.push(IXTemplate.Create({ value: testName, id: id }));
// Create an object with the id and proxy it.
// This will match the id of the template we just created, so we can set its style.
// This is a great example of not actually needing to create a class, which is really
// just a dictionary.
let obj = {};
// The classList here allows us to set the test LI element style class
// to indicate success/failure of the test.
obj[id] = { classList: new IXClassList() };
let testProxy = IX.CreateProxy(obj);
// Create the DOM needed for the test.
this.CreateTestDom(testForm, test.dom);
// Run the test and indicate the result.
this.RunTest(testForm, idx, testProxy, test, id);
// Remove the DOM needed for the test.
this.RemoveTestDom(testForm);
++idx;
});
我们有这三个辅助函数
CreateTestDom(testForm: TestResults, testDom: string): void {
testForm.testDom = testDom || "";
}
RemoveTestDom(testForm: TestResults, ): void {
testForm.testDom = "";
}
RunTest(testForm: TestResults, idx:number, testProxy: object, test, id: string): void {
let passFail = "pass";
try {
test.testFnc(test.obj, id);
} catch (err) {
passFail = "fail";
let template = testForm.tests[idx];
template.SetValue(`${template.value} => ${err}`);
}
testProxy[id].classList.Add(passFail);
}
通过的测试显示为绿色,失败的测试显示为红色,并附有错误消息。
.pass {
color: green;
}
.fail {
color: red;
}
例如,我们可以测试故障处理
static ShouldFail(obj): void {
throw "Failed!!!";
}
我们看到
定义测试
测试被定义为指定以下内容的对象数组
- 要运行的测试。
- 测试中正在操作的“对象”。
- 支持测试的 HTML。
像这样
let tests = [
// { testFnc: IntegrationTests.ShouldFail },
{ testFnc: IntegrationTests.InputElementSetOnInitializationTest,
obj: { inputTest: "Test" }, dom: "<input id='inputTest'/>" },
{ testFnc: IntegrationTests.InputElementSetOnAssignmentTest,
obj: { inputTest: "" }, dom: "<input id='inputTest'/>" },
{ testFnc: IntegrationTests.InputSetsPropertyTest,
obj: { inputTest: "" }, dom: "<input id='inputTest'/>" },
{ testFnc: IntegrationTests.ListInitializedTest,
obj: { list: ["A", "B", "C"] }, dom: "<ol id='list'></ol>" },
{ testFnc: IntegrationTests.ReplaceInitializedTest,
obj: { list: ["A", "B", "C"] }, dom: "<ol id='list'></ol>" },
{ testFnc: IntegrationTests.ChangeListItemTest,
obj: { list: ["A", "B", "C"] }, dom: "<ol id='list'></ol>" },
{ testFnc: IntegrationTests.PushListItemTest,
obj: { list: ["A", "B", "C"] }, dom: "<ol id='list'></ol>" },
{ testFnc: IntegrationTests.PopListItemTest,
obj: { list: ["A", "B", "C"] }, dom: "<ol id='list'></ol>" },
{
testFnc: IntegrationTests.ButtonClickTest,
obj: { clicked: false, onButtonClicked : new IXEvent().Add((_, p) => p.clicked = true)},
dom: "<button id='button'></button>"
},
{
testFnc: IntegrationTests.OnlyOneClickEventTest,
obj: { clicked: 0, onButtonClicked: new IXEvent().Add((_, p) => p.clicked += 1) },
dom: "<button id='button'></button>"
},
{
testFnc: IntegrationTests.CheckboxClickTest,
obj: { clicked: false, checkbox: false,
onCheckboxClicked: new IXEvent().Add((_, p) => p.clicked = p.checkbox)},
dom: "<input id='checkbox' type='checkbox'/>"
},
{
testFnc: IntegrationTests.RadioButtonClickTest,
obj: { clicked: false, checkbox: false,
onRadioClicked: new IXEvent().Add((_, p) => p.clicked = p.radio) },
dom: "<input id='radio' type='radio'/>"
},
{
testFnc: IntegrationTests.ConvertTest,
obj: { inputTest: "", onConvertInputTest: s => `${s} Converted!` },
dom: "<input id='inputTest'/>"
},
{ testFnc: IntegrationTests.VisibleAttributeTest,
obj: { inputTest: { attr: { visible: true } } }, dom: "<input id='inputTest'/>" },
{ testFnc: IntegrationTests.ControlBindingTest,
obj: { input: "123", output: new IXBinder({ bindFrom: "input" }) },
dom: "<input id='input'><p id='output'>" },
{ testFnc: IntegrationTests.ControlBindingWithOperationTest,
obj: { input: "123", output: new IXBinder({ bindFrom: "input",
op: v => `${v} Operated!` }) }, dom: "<input id='input'><p id='output'>" },
{ testFnc: IntegrationTests.ControlBindingAssignmentTest,
obj: { input: "", output: new IXBinder({ bindFrom: "input" }) },
dom: "<input id='input'><p id='output'>" },
];
我不会用实际的测试来烦你,但我会指出,在某些情况下我们必须模拟点击,因此测试必须分发适当的事件,例如
static ButtonClickTest(obj): void {
let test = IX.CreateProxy(obj);
let el = document.getElementById("button") as HTMLButtonElement;
el.dispatchEvent(new Event('click'));
IXAssert.Equal(test.clicked, true);
}
IXAssert 辅助函数
这个类只是将 `if` 语句包装成一行代码,因为我相当不喜欢用于断言的 `if` 语句。
export class IXAssert {
public static Equal(got: any, expected: any): void {
let b = got == expected;
if (!b) {
throw `Expected ${expected}, got ${got}`;
}
}
public static IsTrue(b: boolean): void {
if (!b) {
throw "Not true";
}
}
}
你不需要 TypeScript 类
你应该从测试的实现方式中意识到,你不需要实际的 TypeScript 类,你只需要一个对象,比如 `obj: { inputTest: "Test" }` —— 毕竟,TypeScript 类纯粹是用于 IDE 类型检查和 Intellisense 的开发端构造。即使是 JavaScript 类也只是“JavaScript 现有基于原型的继承的语法糖”。(JavaScript 类)
幕后
TypeScript 不意味着运行时类型反射
TypeScript 在*编写*代码时对于确保类型安全非常出色。然而,当代码被转译为 JavaScript 时,IDE 使用的所有类型信息当然会丢失。这很不幸,因为有时我真的希望代码中有类型信息。有一些变通方法,例如对于原生类型和类
let a = 1;
let b = "foo";
let c = true;
let d = [];
let e = new SomeClass();
[a, b, c, d, e].forEach(q => console.log(q.constructor.name));
let listForm = IX.CreateProxy(new ListExample());
你得到
这很有用。然而,鉴于这个类
class SomeClass {
a: number;
b: string;
}
这被转译成一个空对象 `{}。所以,`Object.keys(new SomeClass())` 返回一个空数组 `[]`。要确定类的属性,属性必须被初始化,它们甚至可以被初始化为 `null` 或 undefined
class SomeClass {
a: number = null;
b: string = undefined;
}
因此,IX 中存在一个约束,即你必须初始化属性,否则无法在类属性和具有属性名称 ID 的元素之间建立连接。
Proxy 初始化
public static CreateProxy<T>(container: T): T {
let proxy = new Proxy(container, IX.uiHandler);
IX.CreatePropertyHandlers(container, proxy);
IX.CreateButtonHandlers(container, proxy);
IX.CreateBinders(container, proxy);
IX.Initialize(container, proxy);
return proxy;
}
除了实例化代理,我们还可以看到还需要几个其他步骤
- 特殊属性处理器
- 按钮处理程序
- 绑定器
- 最终初始化
CreatePropertyHandlers
此代码旨在处理属性、类列表和事件连接。事件只连接一次,以防在类属性赋值后重新初始化代理。为了使事情复杂一些,这里处理了特定情况,例如将 `attr` 键代理以适应将属性分配给关联 DOM 元素的自定义语法。`class` 属性的处理方式类似,为 `classList` 键创建了一个代理。在结论中讨论了更好的实现。否则,该函数的最初目的仅是处理 `mouseover`、`change` 和 `keyup` 事件。
private static CreatePropertyHandlers<T>(container: T, proxy: T) {
Object.keys(container).forEach(k => {
let el = document.getElementById(k);
let anonEl = el as any;
// If element exists and we haven't assigned a proxy to the container's field,
// then wire up the events.
if (el && !anonEl._proxy) {
anonEl._proxy = this;
if (container[k].attr) {
// Proxy the attributes of the container so we can intercept the setter for attributes
console.log(`Creating proxy for attr ${k}`);
container[k].attr = IXAttributeProxy.Create(k, container[k].attr);
}
if (container[k].classList) {
console.log(`Creating proxy for classList ${k}`);
container[k].classList = IXClassListProxy.Create(k, container[k].classList);
}
let idName = IX.UpperCaseFirstChar(el.id);
// TODO: create a dictionary to handle this.
let changedEvent = `on${idName}Changed`;
let hoverEvent = `on${idName}Hover`;
let keyUpEvent = `on${idName}KeyUp`;
if (container[hoverEvent]) {
IX.WireUpEventHandler(el, container, proxy, null, "mouseover", hoverEvent);
}
// Change event is always wired up so we set the container's value
// when the UI element value changes.
switch (el.nodeName) {
case "SELECT":
case "INPUT":
// TODO: If this is a button type, then what?
IX.WireUpEventHandler(el, container, proxy, "value", "change", changedEvent);
break;
}
if (container[keyUpEvent]) {
switch (el.nodeName) {
case "INPUT":
// TODO: If this is a button type, then what?
IX.WireUpEventHandler(el, container, proxy, "value", "keyup", keyUpEvent);
break;
}
}
}
});
}
很明显,这是一个非常不完整的实现,仅足以用于概念验证。
WireUpEventHandler
附加到事件监听器的事件处理程序对 `SELECT` HTML 元素实现了一个自定义检查,并假设类属性已使用 `IXSelector` 实例初始化。这样做是为了在选择时将选定项目的文本和值分配给 `IXSelector` 实例。否则,事件处理程序会更新类的属性(即被代理的类)。因为“按钮”没有属性,而只是一个事件,所以我们检查 DOM 上是否确实存在需要读取并设置到相应类属性上的属性。最后,如果类实现了事件处理程序,则会触发任何多播事件。如果类中定义了自定义转换器,则会首先为非按钮事件调用它。
private static WireUpEventHandler<T>(el: HTMLElement, container: T, proxy: T,
propertyName: string, eventName: string, handlerName: string) {
el.addEventListener(eventName, ev => {
let el = ev.srcElement as HTMLElement;
let oldVal = undefined;
let newVal = undefined;
let propName = undefined;
let handler = container[handlerName];
switch (el.nodeName) {
case "SELECT":
let elSelector = el as HTMLSelectElement;
let selector = container[el.id] as IXSelector;
selector.value = elSelector.value;
selector.text = elSelector.options[elSelector.selectedIndex].text;
break;
default:
// buttons are click events, not change properties.
if (propertyName) {
oldVal = container[el.id];
newVal = el[propertyName];
propName = el.id;
}
let ucPropName = IX.UpperCaseFirstChar(propName ?? "");
if (propertyName) {
newVal = IX.CustomConverter(proxy, ucPropName, newVal);
container[propName] = newVal;
}
break;
}
if (handler) {
(handler as IXEvent).Invoke(newVal, proxy, oldVal);
}
});
}
同样,足以实现概念验证。
CustomConverter
private static CustomConverter<T>(container: T, ucPropName: string, newVal: string): any {
let converter = `onConvert${ucPropName}`;
if (container[converter]) {
newVal = container[converter](newVal);
}
return newVal;
}
CreateButtonHandlers
按钮(以及类似按钮的元素,如复选框和单选按钮)有其独特的要求。复选框和单选按钮(它们是 `INPUT` HTML 元素)有一个 `checked` 属性,而按钮没有。代理的类必须实现预期的“on....”,并且必须将其分配给 `IXEvent` 以支持多播事件。
private static CreateButtonHandlers<T>(container: T, proxy: T) {
Object.keys(container).forEach(k => {
if (k.startsWith("on") && k.endsWith("Clicked")) {
let elName = IX.LeftOf(IX.LowerCaseFirstChar(k.substring(2)), "Clicked");
let el = document.getElementById(elName);
let anonEl = el as any;
if (el) {
if (!anonEl._proxy) {
anonEl._proxy = this;
}
if (!anonEl._clickEventWiredUp) {
anonEl._clickEventWiredUp = true;
switch (el.nodeName) {
case "BUTTON":
IX.WireUpEventHandler(el, container, proxy, null, "click", k);
break;
case "INPUT":
// sort of not necessary to test type but a good idea,
// especially for checkboxes and radio buttons.
let typeAttr = el.getAttribute("type");
if (typeAttr == "checkbox" || typeAttr == "radio") {
IX.WireUpEventHandler(el, container, proxy, "checked", "click", k);
} else {
IX.WireUpEventHandler(el, container, proxy, null, "click", k);
}
break;
}
}
}
}
});
}
CreateBinders
绑定器处理实时事件,例如按键抬起以及输入元素失去焦点。增加复杂性的是,绑定器可能与多个复选框或单选按钮相关联,并绑定当前选定项目的列表。这是一段令人困惑的代码,因为数组和非数组属性都可以绑定。假设如果绑定的是数组,则该数组填充了选定的复选框或单选按钮(尽管从技术上讲,单选按钮应该是排他的)。否则,属性本身会设置为选中状态或元素的值。最后,可以在值设置到*代理*上之前定义一个可选的“op”(操作)。在代理上设置值而不是在*代理*对象上设置值会调用代理的 setter,它可以定义更多行为,但最终也会将值分配给原始容器对象。
// We assume binders are created on input elements. Probably not a great assumption.
private static CreateBinders<T>(container: T, proxy: T): void {
Object.keys(container).forEach(k => {
if (container[k].binders?.length ?? 0 > 0) {
let binderContainer = container[k] as IXBinder;
let binders = binderContainer.binders as IXBind[];
if (binderContainer.asArray) {
binders.forEach(b => {
let elName = b.bindFrom;
let el = document.getElementById(elName);
let typeAttr = el.getAttribute("type");
// Limited support at the moment.
if (typeAttr == "checkbox" || typeAttr == "radio") {
el.addEventListener("click", ev => {
let values: string[] = [];
// Get all the items currently checked
binders.forEach(binderItem => {
let boundElement = (document.getElementById(binderItem.bindFrom)
as HTMLInputElement);
let checked = boundElement.checked;
if (checked) {
values.push(boundElement[binderItem.attribute]);
}
});
let ret = binderContainer.arrayOp(values);
proxy[k] = ret;
});
}
});
} else {
binders.forEach(b => {
let elName = b.bindFrom;
let el = document.getElementById(elName);
console.log(`Binding receiver ${k} to sender ${elName}`);
let typeAttr = el.getAttribute("type");
if (typeAttr == "checkbox" || typeAttr == "radio") {
el.addEventListener("click", ev => {
let boundAttr = b.attribute ?? "checked";
let v = String((ev.currentTarget as HTMLInputElement)[boundAttr]);
v = b.op === undefined ? v : b.op(v);
proxy[k] = v;
});
} else {
// Realtime typing
el.addEventListener("keyup", ev => {
let v = (ev.currentTarget as HTMLInputElement).value;
// proxy[elName] = v; --- why?
v = b.op === undefined ? v : b.op(v);
proxy[k] = v;
});
// Lost focus, or called when value is set programmatically in the proxy setter.
el.addEventListener("changed", ev => {
let v = (ev.currentTarget as HTMLInputElement).value;
v = b.op === undefined ? v : b.op(v);
proxy[k] = v;
});
}
});
}
}
});
}
初始化
最后一个函数开始很简单,但最终变得更复杂,因为它不仅需要处理原生非数组类型,还需要处理数组和 DOM 元素,如“`select`”
private static Initialize<T>(container: T, proxy: T): void {
Object.keys(container).forEach(k => {
let name = container[k].constructor?.name;
switch (name) {
case "String":
case "Number":
case "Boolean":
case "BigInt":
proxy[k] = container[k]; // Force the proxy to handle the initial value.
break;
case "Array":
// Special handling of arrays that have an initial set of elements
// so we don't duplicate the elements.
// At this point, container[k] IS the proxy (IXArrayProxy)
// so we have the issue that the proxy is set to
// the array but the UI elements haven't been created. If we just do:
// proxy[k] = container[k];
// This will initialize the UI list but push duplicates of the into the array.
// So, for arrays, we want to create the array proxy
// as an empty array during initialization instead,
// then set the empty proxy to the container, then the container to the proxy.
if (container[k]._id != k) {
let newProxy = IXArrayProxy.Create(k, container);
newProxy[k] = container[k];
container[k] = newProxy;
}
break;
case "IXSelector":
// Similar to "Array" above, except we are proxying the IXSelector.options array,
// not the container itself.
if (container[k]._id != k) {
// Set the element that this IXSelector manages
// so we know what to do when value and text are assigned.
container[k]._element = document.getElementById(k);
let selector = container[k] as IXSelector;
// Proxy the options array so we can initialize it as well as push/pop.
if (selector.options.length > 0) {
let newProxy = IXArrayProxy.Create(k, container);
newProxy[k] = selector.options;
selector.options = newProxy;
}
}
break;
}
});
}
数组
数组有点像一场噩梦。数组函数,如 `push`、`pop` 和 `length`,实际上是通过代理的 getter 进行向量化处理的(就像代理对象上的任何其他函数一样)
static ArrayChangeHandler = {
get: function (obj, prop, receiver) {
// return true for this special property,
// so we know that we're dealing with a ProxyArray object.
if (prop == "_isProxy") {
return true;
}
// Setup for push and pop, preserve state when the setter is called.
// Very kludgy but I don't know of any other way to do this.
if (prop == "push") {
receiver._push = true;
}
if (prop == "pop") {
receiver._pop = true;
}
if (prop == "length") {
return obj[receiver._id].length;
}
return obj[prop];
},
请注意,正在设置一个标志,指示在 setter 中即将执行的操作是 `push` 还是 `pop`!此信息用于确定当长度改变时,数组在 setter 中应如何调整。事实证明,弹出数组元素只会改变数组的长度
set: function (obj, prop, val, receiver) {
// we're looking for this pattern:
// "setting 0 for someList with value Learn Javascript"
let id = receiver._id;
console.log('setting ' + prop + ' for ' + id + ' with value ' + val);
if (prop == "length" && receiver._pop) {
let el = document.getElementById(id);
let len = obj[id].length;
for (let i = val; i < len; i++) {
el.childNodes[val].remove();
obj[id].pop();
}
receiver._pop = false;
} else {
如果 setter 不是 pop,那么它正在更新数组中的现有项
// We might be setting an array item, or we might be doing a push,
// in either case "prop" is the index value.
if (!isNaN(prop)) {
let el = document.getElementById(id);
switch (el.nodeName) {
// TODO: "UL"!
case "OL": {
let n = Number(prop);
let ol = el as HTMLOListElement;
if (n < ol.childNodes.length && !receiver._push) {
// We are replacing a node
// innerText or innerHTML?
(ol.childNodes[n] as HTMLLIElement).innerText = val;
或者我们正在向数组中添加一个项目
} else {
let li = document.createElement("li") as HTMLLIElement;
let v = val;
if (val._isTemplate) {
let t = val as IXTemplate;
// innerText or innerHTML?
li.innerText = t.value;
li.id = t.id;
v = t.value;
} else {
li.innerText = val;
}
(el as HTMLOListElement).append(li);
obj[id].push(v);
receiver._push = false;
}
最后,数组属性可能会被设置为一个全新的数组
} else if (val.constructor.name == "Array") {
let el = document.getElementById(id);
// TODO: remove all child elements?
switch (el.nodeName) {
case "SELECT":
(val as IXOption[]).forEach(v => {
let opt = document.createElement("option") as HTMLOptionElement;
opt.innerText = v.text;
opt.value = String(v.value);
opt.disabled = v.disabled;
opt.selected = v.selected;
(el as HTMLSelectElement).append(opt);
});
break;
case "OL":
case "UL":
(val as []).forEach(v => {
let li = document.createElement("li") as HTMLLIElement;
li.innerText = v;
(el as HTMLOListElement).append(li);
});
break;
}
}
IXEvent
`IXEvent`(及其辅助类 `IXSubscriber`)是实现多播事件的包装器
export class IXSubscriber {
subscriber: (obj: any, oldVal: string, newVal: string) => void;
constructor(subscriber: (obj: any, oldVal: string, newVal: string) => void) {
this.subscriber = subscriber;
}
Invoke(obj: any, oldVal: string, newVal: string): void {
this.subscriber(obj, oldVal, newVal);
}
}
import { IXSubscriber } from "./IXSubscriber"
export class IXEvent {
subscribers: IXSubscriber[] = [];
// We probably only usually want the new value, followed by the container,
// followed by the old value.
Add(subscriber: (newVal: string, obj: any, oldVal: string) => void) : IXEvent {
this.subscribers.push(new IXSubscriber(subscriber));
return this;
}
Invoke(newVal: string, obj: any, oldVal: string): void {
this.subscribers.forEach(s => s.Invoke(newVal, obj, oldVal));
}
}
IXTemplate
这个类是我为模板提供功能的蹩脚尝试。
export class IXTemplate {
public _isTemplate: boolean = true;
public value?: string;
public id?: string;
public static Create(t: any): IXTemplate {
let template = new IXTemplate();
template.value = t.value;
template.id = t.id;
return template;
}
public SetValue(val: string): void {
document.getElementById(this.id).innerText = val;
}
}
这充其量是可疑的,因为它设置了 `innerText` 而不是 `innerHtml`,而且我真的不确定它的用处,除了它在集成测试中使用。
结论
走到 U 的另一端,我现在正在重新考虑整个实现。
我需要 Proxy 吗?
当我实现 `combobox` 的一个示例时,并使用这种结构
selector = new IXSelector();
我想到,嗯,也许所有映射到 DOM 元素的类属性都应该被一个实际的“helper”包装起来。这将允许包装器直接实现元素的 DOM 属性和特性,从而有效地消除了对 Proxy 的需求!它还将消除“自己创造语法”的情况,比如
.Add({ bindFrom: "jane", attribute: "value" })
或
mySpan = {
attr: { title: "" }
};
“初始化”过程将仅仅遍历类属性(它们仍然必须存在)并用 DOM ID 初始化属性,因此特定实现可以直接操作 DOM,而不是通过代理。而且我仍然会有 Intellisense,因为包装器实现会包含我将要接触的 DOM 属性和特性。
当然,这需要大量的工作——理想情况下,每个元素的 DOM 都必须重新实现,而这仅仅适用于原生的 HTML 元素。那么第三方 UI 库呢?一种方法是逐步完成,根据我在使用该框架编写的 Web 应用程序中的需求进行。此外,我可以从已经存在的 HTML....接口(例如 HTMLButtonElement)派生此类包装器类。这会奏效,但我也非常喜欢这种美妙之处
nc.name = "Hello World!";
Proxy 和 DOM 包装器
没有理由实现不能通过检查构造函数名称来同时支持两者,就像我已经在初始化过程中所做的那样。因此,这将由开发人员决定
- 对于原生类型,代理将封装基本行为。
- 对于包装器类型,将不使用代理,从而使开发人员能够更精细地控制元素。
这也将消除开发人员需要学习的任何晦涩的语法,因为包装器将实现已经存在的 HTML 接口,并且人们已经熟悉这些接口。
此外,所有处理数组 push、pop 和赋值的繁琐代码都会变得更加简洁!
未来正在显现
因此,我只能得出结论,在经历了 U 过程之后,我现在知道了未来会是什么样子,至少对于 *我* 想要使用的框架来说!
历史
- 2020年7月5日:初始版本