在本文中,Noam Rosenthal深入研究了一些跨框架通用的技术特性,并解释了一些不同的框架如何实现它们以及它们的成本。
Noam Rosenthal是一名WEB平台顾问,WebKit & Chromium贡献者、技术文章写作者,也是一名经验丰富的WEB开发者。他的工作重点是让WEB开发和浏览器/标准开发二者之间联系的更加紧密。
我最近对比较框架和普通的JavaScript非常感兴趣。它开始于我在一些项目中使用React时遇到的一些挫折,以及我最近作为一个规范编辑对web标准有了更深入的了解。
我感兴趣的是这些框架之间有什么共同点和不同点,web平台作为一个精简的替代方案应该提供什么,以及它是否足够。我的目标不是抨击框架,而是了解其成本和收益,确定是否存在替代方案,并看看即使我们决定使用框架,我们是否可以从中学习。
在第一部分中,我将深入研究一些跨框架通用的技术特性,以及不同框架如何实现这些特性。我还将讨论使用这些框架的成本。
我选择了4个框架来研究:React,它是当今的主流框架,还有3个新的竞争者声称他们的工作方式与React不同。
总结一下这些框架的不同点:
框架本身提到了声明性、响应式和虚拟DOM。让我们来探究一下这意味着什么。
声明式编程是一种定义逻辑而不指定控制流的范例。我们描述的是结果需要是什么,而不是我们需要采取什么步骤才能达到目标。
在声明式框架的早期,大约在2010年,DOM api要简单和冗长得多,用命命式JavaScript编写web应用程序需要大量的样板代码。这时,“模型-视图-视图模型”(MVVM)[1]的概念开始流行起来,当时具有开创性的Knockout和AngularJS框架提供了一个JavaScript声明层来处理库中的复杂性。
MVVM现在不是一个广泛使用的术语,它在某种程度上是旧术语“数据绑定”的变体。
数据绑定是一种声明性的方式,用来表示数据如何在模型和用户界面之间同步。
所有流行的UI框架都提供了某种形式的数据绑定,它们的教程都从一个数据绑定示例开始。
下面是JSX中的数据绑定(SolidJS和React):
function HelloWorld() {
const name = "Solid or React";
return (
<div>Hello {name}!</div>
)
}
Lit中的数据绑定:
class HelloWorld extends LitElement {
@property()
name = 'lit';
render() {
return html`<p>Hello ${this.name}!</p>`;
}
}
Svelte中的数据绑定:
<script>
let name = 'world';
</script>
<h1>Hello {name}!</h1>
响应式是一种表达变更传播的声明性方式。
当我们有了一种声明式表达数据绑定的方法时,我们需要一种有效的方法让框架传播更改。
React引擎会将渲染结果与之前的结果进行比较,并将差异应用到DOM本身。这种处理变更传播的方法称为虚拟DOM[2]。
在SolidJS中,这是通过其存储和内置元素更显式地完成的。例如,Show元素将跟踪内部发生的变化,而不是虚拟DOM。
在Svelte中,会生成“响应式”代码。Svelte知道哪些事件会导致更改,并生成简单的代码,在事件和DOM更改之间划线。
在Lit中,响应式是使用元素属性完成的,本质上依赖于HTML自定义元素的内置响应式。
当框架为数据绑定提供声明性接口,并实现响应式时,它还需要提供某种方式来表达一些传统上以命定方式编写的逻辑。逻辑的基本构建块是“if”和“for”,所有主要的框架都提供了这些构建块的一些表达式。
除了绑定数字和字符串等基本数据外,每个框架都提供一个“条件”原语。在React中,它是这样的:
const [hasError, setHasError] = useState(false);
return hasError ? <label>Message</label> : null;
…
setHasError(true);
SolidJS提供了一个内置的条件组件Show[3]:
<Show when={state.error}>
<label>Message</label>
</Show>
Svelte提供了#if指令:
{#if state.error}
<label>Message</label>
{/if}
在Lit中,你可以在渲染函数中使用一个显式的三目运算操作:
render() {
return this.error ? html`<label>Message</label>`: null;
}
另一个常见的框架原语是列表处理。列表是ui的关键部分---联系人列表、通知列表等等——为了有效地工作,它们需要是响应式的,而不是在一个数据项发生变化时更新整个列表。
在React中,列表处理是这样的:
contacts.map((contact, index) =>
<li key={index}>
{contact.name}
</li>)
React使用特殊的key属性来区分列表项,并确保整个列表不会在每次渲染时被替换。
在SolidJS中,for和index是作为内置元素被使用的:
<For each={state.contacts}>
{contact => <DIV>{contact.name}</DIV> }
</For>
在内部,SolidJS使用自己的存储库和for和索引来决定在项目更改时更新哪些元素。它比React更显式,允许我们避免虚拟DOM的复杂性。
Svelte使用了each指令,根据它的更新器进行编译:
{#each contacts as contact}
<div>{contact.name}</div>
{/each}
Lit提供了一个repeat函数,它的工作原理类似于React的键列表映射:
repeat(contacts, contact => contact.id,
(contact, index) => html`<div>${contact.name}</div>`
有一件事超出了本文的范围,那就是不同框架中的组件模型,以及如何使用自定义HTML元素来处理它。
注: 这是一个很大的主题,我希望在以后的文章中讨论它,因为这篇文章太长了。
框架提供了声明性的数据绑定、控制流原语(条件和列表)和响应机制来传播更改。
它们还提供了其他主要功能,比如重用组件的方法,但这是另一篇文章的主题。
框架有用吗?是的。它们给了我们所有这些方便的特性。但这个问题问对了吗?使用框架是有代价的。让我们看看这些成本是多少。
在查看打包后的包大小时,我喜欢查看压缩后的非gzip大小。这是与JavaScript执行的CPU成本最相关的大小。
不知怎么的,我们习惯了“构建”我们的网络应用。要启动一个前端项目,必须先建立Node.js和Webpack这样的打包工具,处理Babel-TypeScript的一些配置等等。
框架的包大小越小,表达能力越强,构建工具和翻译时间的负担就越大。
Svelte声称虚拟DOM是纯粹的开销[4]。这一点我同意,但也许“构建”(如使用Svelte和SolidJS)和定制客户端模板引擎(如使用Lit)也是纯粹的开销,是一种不同的表现形式?
构建和编译带来了一定的开销和成本。
当我们使用或调试web应用程序时,我们看到的代码与我们写的完全不同。我们现在依赖于不同质量的特殊调试工具来逆向工程网站上发生的事情,并将其与我们自己代码中的错误联系起来。
在React中,调用栈从来不是“你的”——React为你处理调度。在没有bug的情况下,这种方法非常有效。但是尝试着去识别无限循环重新呈现的原因,你将会经历一个痛苦的世界。
在Svelte中,库本身的包大小很小,但你需要发布和调试一大堆神秘的生成代码,这是Svelte的响应式实现,根据应用的需要定制。
使用Lit,它与构建无关,但要有效地调试它,您必须理解它的模板引擎。这可能是我对框架持怀疑态度的最大原因。
当您寻找自定义声明式解决方案时,您最终会遇到更痛苦的命令式调试。本文档中的示例使用Typescript作为API规范,但代码本身不需要编译。
在本文档中,我介绍了4个框架,但还有很多框架(AngularJS、Ember.js和Vue.js等[5])。在它的发展过程中,你能指望这个框架、它的开发者、它的人气和它的生态系统为你服务吗?
有一件事比修复自己的漏洞更令人沮丧,那就是必须为框架漏洞找到变通方法。还有一件事比框架bug更令人沮丧,那就是当你没有修改代码就将框架升级到一个新版本时出现的bug。
确实,这个问题也存在于浏览器中,但是当它发生时,它会发生在每个人身上,并且在大多数情况下,修复或发布的解决方案是迫在眉睫的。此外,本文档中的大多数模式都是基于成熟的web平台api;没有必要总是去流血的边缘。
我们深入了解了框架试图解决的核心问题,以及它们如何解决这些问题,重点关注数据绑定、响应式、条件和列表。我们也看了成本。
在后面的部分,我们将了解如何在根本不使用框架的情况下解决这些问题,以及我们可以从中学到什么。请继续关注!
特别感谢以下每个人的勘校:Yehonatan Daniv, Tom Bigelajzen, Benjamin Greenbaum, Nick Ribal和Louis Lazaris。
在第二部分中,Noam提出了一些如何直接使用web平台作为框架提供的一些解决方案的替代方案的模式。
在前面的第一章节中,我们从框架试图解决的核心问题的角度出发,研究了使用框架的不同好处和成本,重点关注声明式编程、数据绑定、响应式、列表和条件。现在,我们将看到是否可以从网络平台本身出现一个替代方案。
在没有框架的情况下进行探索,似乎不可避免的结果是使用自己的框架来进行响应式数据绑定。在之前尝试过这种方法,并看到它的代价有多大后,我决定在这次探索中遵循一条指导原则;我并不是要推出我自己的框架,而是想看看我能否以一种让框架变得不那么必要的方式直接使用web平台。如果您考虑使用自己的框架,请注意有一组成本没有在本文中讨论。
web平台已经提供了一种开箱即用的声明式编程机制:HTML和CSS。这种机制是成熟的、经过良好测试的、流行的、广泛使用的和有文档记载的。但是,它没有提供明确的内置概念,如数据绑定、条件呈现和列表同步,而响应式是跨多个平台特性的一个微妙细节。
当我浏览流行框架的文档时,我可以直接找到第1部分中描述的特性。当我阅读web平台文档时(例如,在MDN[6]上),我发现了许多令人困惑的如何做事的模式,没有数据绑定、列表同步或响应式的结论性表示。我将尝试绘制一些在web平台上解决这些问题的指导方针,而不需要框架(换句话说,通过普通的方式)。
让我们回到错误标签的例子。在ReactJS和SolidJS中,我们创建的声明式代码转换为命令式代码,将标签添加到DOM或删除它。在Svelte中,生成该代码。
但是如果我们根本没有这些代码,而是使用CSS来隐藏和显示错误标签呢?
<style>
label.error { display: none; }
.app.has-error label.error {display: block; }
</style>
<label class="error">Message</label>
<script>
app.classList.toggle('has-error', true);
</script>
在这种情况下,响应式在浏览器中处理——应用程序对类的更改传播到它的后代,直到浏览器中的内部机制决定是否呈现标签。
这种技术有几个优点:
在使用大量javascript的单页应用程序(spa)时代之前,表单是创建包含用户输入的web应用程序的主要方式。传统上,用户将填写表单并单击“Submit”按钮,然后服务器端代码将处理响应。表单是数据绑定和交互性的多页应用程序版本。毫无疑问,具有输入和输出基本名称的HTML元素是表单元素。
由于表单api的广泛使用和悠久的历史,它积累了一些隐藏的优点,使得它们可以用于那些传统上认为由表单解决不了的问题。
表单可以通过名称访问(使用document.forms "document.forms"),每个表单元素都可以通过名称访问(使用form.elements)。此外,可以访问与元素相关联的表单(使用form attributes[7])。这不仅包括input元素,还包括其他表单元素,如output、textarea和fieldset,这允许嵌套访问树中的元素。
在上一节的错误标签示例中,我们展示了如何响应式地显示和隐藏错误消息。这是我们在React中更新错误消息文本的方法(在SolidJS中也是如此):
const [errorMessage, setErrorMessage] = useState(null);
return <label className="error">{errorMessage}</label>
当我们有一个稳定的DOM和稳定的树形式和表单元素时,我们可以做以下事情:
<form name="contactForm">
<fieldset name="email">
<output name="error"></output>
</fieldset>
</form>
<script>
function setErrorMessage(message) {
document.forms.contactForm.elements.email.elements.error.value = message;
}
</script>
它的原始形式看起来相当冗长,但它也非常稳定、直接和高性能。
通常,当我们构建SPA时,我们会使用一些类似json的API来更新我们的服务器或我们使用的任何模型。
这是一个很熟悉的例子(为了便于阅读,是用Typescript写的):
interface Contact {
id: string;
name: string;
email: string;
subscriber: boolean;
}
function updateContact(contact: Contact) { … }
在框架代码中,通过选择输入元素并一块一块地构造对象来生成这个Contact对象是很常见的。正确使用表单,有一个简洁的替代方案:
<form name="contactForm">
<input name="id" type="hidden" value="136" />
<input name="email" type="email"/>
<input name="name" type="string" />
<input name="subscriber" type="checkbox" />
</form>
<script>
updateContact(Object.fromEntries(
new FormData(document.forms.contactForm));
</script>
通过使用隐藏的输入和有用的FormData类,我们可以在DOM输入和JavaScript函数之间无缝地转换值。
通过结合表单的高性能选择器稳定性和CSS响应式,我们可以实现更复杂的UI逻辑:
<form name="contactForm">
<input name="showErrors" type="checkbox" hidden />
<fieldset name="names">
<input name="name" />
<output name="error"></output>
</fieldset>
<fieldset name="emails">
<input name="email" />
<output name="error"></output>
</fieldset>
</form>
<script>
function setErrorMessage(section, message) {
document.forms.contactForm.elements[section].elements.error.value = message;
}
function setShowErrors(show) {
document.forms.contactForm.elements.showErrors.checked = show;
}
</script>
<style>
input[name="showErrors"]:not(:checked) ~ * output[name="error"] {
display: none;
}
</style>
注意,在这个例子中没有使用类——我们从表单的数据中开发DOM的行为和样式,而不是手工更改元素类。
我不喜欢过度使用CSS类作为JavaScript选择器。我认为它们应该用于将类似样式的元素组合在一起,而不是作为一种改变组件样式的万能机制。
框架提供了自己的表达可观察列表的方式。如今,许多开发人员也依赖于提供这类特性的非框架库,比如MobX。
通用目的可观察列表的主要问题是它们是通用的。这在降低性能的同时增加了便利性,而且还需要特殊的开发工具来调试这些库在后台执行的复杂操作。
使用这些库并理解它们的作用是可以的,而且不管UI框架的选择如何,它们都是有用的,但是使用替代方法可能不会更复杂,而且它可能会防止在尝试运行自己的模型时发生的一些陷阱。
ChaCha—也被称为变更通道—是一个双向流,其目的是通知意图方向和观察方向的变更。
ChaCha的接口通常可以从应用的规范中派生出来,而不需要任何UI代码。
例如,一个应用程序允许你添加和删除联系人,并从服务器加载初始列表(带有刷新选项),它可以有这样一个ChaCha:
interface Contact {
id: string;
name: string;
email: string;
}
// "Observe" Direction
interface ContactListModelObserver {
onAdd(contact: Contact);
onRemove(contact: Contact);
onUpdate(contact: Contact);
}
// "Intent" Direction
interface ContactListModel {
add(contact: Contact);
remove(contact: Contact);
reloadFromServer();
}
注意,这两个接口中的所有函数都是void,并且只接收普通对象。这是故意的。ChaCha构建起来就像一个有两个端口的通道来发送消息,这允许它在EventSource、HTML MessageChannel、service worker或任何其他协议中工作。
ChaChas的优点是易于测试:您发送动作并期待特定的调用返回给观察者。
HTML模板是存在于DOM中但不被显示的特殊元素。它们的目的是生成动态元素。
当我们使用模板元素时,我们可以避免所有创建元素并在JavaScript中填充它们的样板代码。
下面将使用模板将一个名字添加到列表中:
<ul id="names">
<template>
<li><label class="name" /></li>
</template>
</ul>
<script>
function addName(name) {
const list = document.querySelector('#names');
const item = list.querySelector('template').content.cloneNode(true).firstElementChild;
item.querySelector('label').innerText = name;
list.appendChild(item);
}
</script>
通过使用列表项的模板元素,我们可以在原始HTML中看到列表项——它不是用JSX或其他语言“呈现”的。你的HTML文件现在包含了应用程序的所有HTML -静态部分是渲染DOM的一部分,动态部分在模板中表示,准备在时机成熟时被克隆和追加到文档中。
TodoMVC[8]是一个TODO列表的应用规范,用于展示不同的框架。TodoMVC模板附带了现成的HTML和CSS,以帮助您专注于框架。
你可以在GitHub库中使用结果[9],完整的源代码[10]是可用的。
我们将从规范[11]开始,并使用它来构建ChaCha接口:
interface Task {
title: string;
completed: boolean;
}
interface TaskModelObserver {
onAdd(key: number, value: Task);
onUpdate(key: number, value: Task);
onRemove(key: number);
onCountChange(count: {active: number, completed: number});
}
interface TaskModel {
constructor(observer: TaskModelObserver);
createTask(task: Task): void;
updateTask(key: number, task: Task): void;
deleteTask(key: number): void;
clearCompleted(): void;
markAll(completed: boolean): void;
}
任务模型中的功能直接从规范和用户可以做的事情中派生出来(清除已完成的任务,将所有任务标记为已完成或活动,获得活动和已完成的计数)。
请注意,它遵循ChaCha的指导原则:
接下来,我将使用TodoMVC模板,并将其修改为面向表单的—表单的层次结构,输入和输出元素表示可以用JavaScript更改的数据。
我如何知道是否需要一个表单元素?根据经验,如果它绑定到模型中的数据,那么它应该是一个表单元素。
完整的HTML代码[12]是可用的,但这里是它的主要部分:
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<form name="newTask">
<input name="title" type="text" placeholder="What needs to be done?" autofocus>
</form>
</header>
<main>
<form id="main"></form>
<input type="hidden" name="filter" form="main" />
<input type="hidden" name="completedCount" form="main" />
<input type="hidden" name="totalCount" form="main" />
<input name="toggleAll" type="checkbox" form="main" />
<ul class="todo-list">
<template>
<form class="task">
<li>
<input name="completed" type="checkbox" checked>
<input name="title" readonly />
<input type="submit" hidden name="save" />
<button name="destroy">X</button>
</li>
</form>
</template>
</ul>
</main>
<footer>
<output form="main" name="activeCount">0</output>
<nav>
<a name="/" href="#/">All</a>
<a name="/active" href="#/active">Active</a>
<a name="/completed" href="#/completed">Completed</a>
</nav>
<input form="main" type="button" name="clearCompleted" value="Clear completed" />
</footer>
</section>
本HTML包括以下内容:
这个HTML不知道它将如何被样式化,也不知道它将绑定到什么数据。让CSS和JavaScript为HTML工作,而不是让HTML为特定的样式机制工作。这将使更改设计变得更加容易。
现在我们在CSS中有了大部分的反应性,并且在模型中有了列表处理,剩下的就是Controller代码——将所有东西连接在一起的管道胶带。在这个小应用程序中,Controller JavaScript[14]大约有40行代码。
下面是一个版本,并对每个部分进行了解释:
import TaskListModel from './model.js';
const model = new TaskListModel(new class {
在上面的代码中,我们创建了一个新模型。
onAdd(key, value) {
const newItem = document.querySelector('.todo-list template').content.cloneNode(true).firstElementChild;
newItem.name = `task-${key}`;
const save = () => model.updateTask(key, Object.fromEntries(new FormData(newItem)));
newItem.elements.completed.addEventListener('change', save);
newItem.addEventListener('submit', save);
newItem.elements.title.addEventListener('dblclick', ({target}) => target.removeAttribute('readonly'));
newItem.elements.title.addEventListener('blur', ({target}) => target.setAttribute('readonly', ''));
newItem.elements.destroy.addEventListener('click', () => model.deleteTask(key));
this.onUpdate(key, value, newItem);
document.querySelector('.todo-list').appendChild(newItem);
}
当一个项目被添加到模型中时,我们会在UI中创建相应的列表项目。
在上面,我们克隆了条目模板的内容,为特定的条目分配了事件监听器,并将新条目添加到列表中。
请注意,这个函数,连同onUpdate、onRemove和onCountChange,都是从模型[15]中调用的回调函数。
onUpdate(key, {title, completed}, form = document.forms[`task-${key}`]) {
form.elements.completed.checked = !!completed;
form.elements.title.value = title;
form.elements.title.blur();
}
当一个项目被更新时,我们设置它的complete和title值,然后失去焦点(退出编辑模式)。
onRemove(key) { document.forms[`task-${key}`].remove(); }
当一个项目从模型中移除时,我们从视图中移除它对应的列表项目。
onCountChange({active, completed}) {
document.forms.main.elements.completedCount.value = completed;
document.forms.main.elements.toggleAll.checked = active === 0;
document.forms.main.elements.totalCount.value = active + completed;
document.forms.main.elements.activeCount.innerHTML = `<strong>${active}</strong> item${active === 1 ? '' : 's'} left`;
}
在上面的代码中,当完成或活动项目的数量发生变化时,我们设置适当的输入来触发CSS反应,并格式化显示计数的输出。
const updateFilter = () => filter.value = location.hash.substr(2);
window.addEventListener('hashchange', updateFilter);
window.addEventListener('load', updateFilter);
然后我们从哈希片段(以及在启动时)更新过滤器。上面我们所做的一切只是设置一个表单元素的值——CSS处理其余的事情。
document.querySelector('.todoapp').addEventListener('submit', e => e.preventDefault(), {capture: true});
这里,我们确保表单提交时不会重新加载页面。就是这条线把这个应用变成了SPA中心。
document.forms.newTask.addEventListener('submit', ({target: {elements: {title}}}) =>
model.createTask({title: title.value}));
document.forms.main.elements.toggleAll.addEventListener('change', ({target: {checked}})=>
model.markAll(checked));
document.forms.main.elements.clearCompleted.addEventListener('click', () =>
model.clearCompleted());
这将处理主要操作(创建、标记全部、清除完成)。
您可以查看完整的CSS代码[16]。
CSS处理规范中的很多要求(为了便于访问,还做了一些修改)。让我们看一些例子。
根据规范,“X”(摧毁)按钮只在悬停时显示。我还添加了一个可访问性位,使其在任务集中时可见:
.task:not(:hover, :focus-within) button[name="destroy"] { opacity: 0 }
当过滤器链接是当前链接时,它会得到一个红色的边框:
.todoapp input[name="filter"][value=""] ~ footer a[href$="#/"],
nav a:target {
border-color: #CE4646;
}
注意,我们可以使用link元素的href作为部分属性选择器——不需要JavaScript检查当前的过滤器,并在适当的元素上设置一个选定的类。
我们还使用:target选择器,这使我们不必担心是否要添加过滤器。
标题输入的视图和编辑样式会根据其只读模式而改变:
.task input[name="title"]:read-only {
…
}
.task input[name="title"]:not(:read-only) {
…
}
筛选(即只显示活动的和已完成的任务)是通过选择器来完成的:
input[name="filter"][value="active"] ~ * .task
:is(input[name="completed"]:checked, input[name="completed"]:checked ~ *),
input[name="filter"][value="completed"] ~ * .task
:is(input[name="completed"]:not(:checked), input[name="completed"]:not(:checked) ~ *) {
display: none;
}
上面的代码可能看起来有点冗长,使用CSS预处理器(如Sass)可能更容易阅读。但是它所做的事情很简单:如果过滤器是活动的,完成的复选框被选中,或者反之亦然,那么我们隐藏复选框和它的兄弟元素。
我选择在CSS中实现这个简单的过滤器,以显示它能走多远,但如果它开始变得复杂,那么它将完全有意义的移动到模型中。
我相信框架为实现复杂的任务提供了方便的方法,而且除了技术方面的好处,比如让一组开发人员遵循特定的风格和模式。web平台提供了许多选择,采用框架可以让每个人至少部分地在某些选择上站在同一页上。这是有价值的。另外,声明式编程的优雅也有值得说明的地方,而组件化的主要特性并不是本文讨论的内容。
但是请记住,存在替代模式,通常成本更低,并不总是需要更少的开发人员经验。允许自己对这些模式感到好奇,即使您决定在使用框架时从中挑选。
再次感谢各位同侪的文章勘校工作:Yehonatan Daniv, Tom Bigelajzen, Benjamin Greenbaum, Nick Ribal, Louis Lazaris。
[1]'模型-视图-视图模型'(MVVM): https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel
[2]虚拟DOM: https://reactjs.org/docs/faq-internals.html#what-is-the-virtual-dom
[3]内置的条件组件Show: https://www.solidjs.com/docs/latest/api#%3Cshow%3E
[4]虚拟DOM是纯粹的开销: https://svelte.dev/blog/virtual-dom-is-pure-overhead
[5]基于javascript的WEB框架对比: https://en.wikipedia.org/wiki/Comparison_of_JavaScript-based_web_frameworks
[6]MDN: https://developer.mozilla.org/zh-CN/
[7]form attributes: https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement
[8]TodoMVC: https://todomvc.com/
[9]the result: https://noamr.github.io/todomvc-app-template/index.html
[10]full source code: https://github.com/noamr/todomvc-app-template
[11]app-spec: https://github.com/tastejs/todomvc/blob/master/app-spec.md
[12]todomvc-app-template: https://github.com/noamr/todomvc-app-template/blob/main/index.html
[13]form attribute: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-form
[14]controller js: https://github.com/noamr/todomvc-app-template/blob/main/js/app.js
[15]model.js: https://github.com/noamr/todomvc-app-template/blob/main/js/model.js
[16]app.css: https://github.com/noamr/todomvc-app-template/blob/main/css/app.css
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8