通过从头构建 UI 框架来学习 JavaScript
在我之前的文章中,我解释了如何将你最喜欢的库和框架的 API 转化为编程课程。今天,我将更进一步地阐述这个想法。我们不会阅读别人的代码。相反,我们将编写自己的代码,并构建一个非常基础的 UI 框架。
构建框架是深化 JavaScript 和编程知识的好方法,因为它迫使你探索语言特性和软件工程原理。例如,所有 Web 框架都试图解决保持应用程序数据与 UI 同步的问题。所有这些问题的解决方案都可能涉及不同的领域,例如路由、DOM 操作、状态管理和异步编程。
解决 UI 状态同步问题的一种更流行的方法是使用虚拟 DOM(简称 vdom)。我们可以使用 JavaScript 对象来响应数据更改,而不是直接操作 DOM,因为它们的操作计算成本更低。vdom 方法可以分解如下:
- 首次加载应用程序时,创建一个描述 UI 的 JavaScript 对象树
- 使用 DOM API 将这些对象转换为 DOM 元素,例如
document.createElement
- 当你需要更改 DOM(响应用户交互、动画或网络请求)时,创建另一个 JavaScript 对象树来描述你的新 UI
- 比较新旧 JavaScript 对象树,查看哪些 DOM 元素发生了变化以及如何
- 只对已更改的地方进行 DOM 更改
任何 vdom 实现的基本部分之一是创建对象的函数。本质上,该函数必须返回一个包含创建 DOM 元素所需信息的对象。例如,为了创建以下 DOM 结构:
<ul class="list">
<li class="list-item" style="color: red;">Item 1</li>
<li class="list-item" style="color: blue;">Item 2</li>
</ul>
您需要了解每个 DOM 元素的以下信息:
- 元素类型
- 属性列表
- 如果它有任何孩子(对于每个孩子,我们还需要知道上面列出的相同信息)
这引出了我们的第一课:数据结构。正如 Linus Torvalds 所说:“糟糕的程序员担心代码。优秀的程序员担心数据结构及其关系。” 那么,我们如何在代码中表示上面的 DOM 结构呢?
{
type: 'ul',
attrs: {
'class': 'list'
},
children: [
{
type: 'li',
attrs: {
class: 'list-item',
style: {
color: 'red'
}
},
},
{
type: 'li',
attrs: {
class: 'list-item',
style: {
color: 'blue'
}
},
}
]
}
我们有一个具有三个属性的对象,每个属性要么是string
,object
要么是array
。我们如何选择这些数据类型?
- 所有 HTML 元素都可以用字符串表示
- HTML 属性
key: value
与对象之间存在良好的关系 - HTML 子节点可以采用列表格式,创建它们需要对列表中的每个项目执行相同的操作。数组非常适合这种情况
现在我们知道了数据结构是什么样的,我们可以继续讨论创建这个对象的函数了。根据我们的输出判断,最简单的做法是创建一个接受三个参数的函数。
createElement (type, attrs, children) {
return {
type: type,
attrs: attrs,
children: children
}
}
我们有函数,但如果调用时没有收到所有参数,会发生什么?此外,创建对象是否需要所有参数都存在?
这将引出我们的下一课:错误处理、默认参数、解构和属性简写。
首先,如果不指定类型,就无法创建 HTML 元素,因此我们需要防范这种情况。对于错误,我们可以借鉴 Mithril抛出错误的方法。或者,我们可以按照此处所述定义自定义错误。
createElement (type, attrs, children) {
if (type == null || typeof type !== 'string') {
throw Error('The element type must be a string');
}
return {
type: type,
attrs: attrs,
children: children
}
}
我们稍后会重新讨论这个检查type == null || typeof type !== 'string'
,但现在先专注于创建对象。虽然我们不能在不指定类型的情况下创建 HTML 元素,但我们可以创建没有子元素或属性的 HTML 元素。
在 JavaScript 中,如果调用函数时未提供任何必需的参数,则这些参数会被undefined
默认赋值。因此,如果用户未指定attrs
, 和children
也会被undefined
赋值。我们不希望出现这种情况,因为正如我们稍后会看到的,其余代码期望这些参数包含一个值。为了解决这个问题,我们将attrs
和children
赋值为默认值:
createElement (type, attrs = {}, children = []) {
if (type == null || typeof type !== 'string') {
throw Error('The element type must be a string');
}
return {
type: type
attrs: attr,
children: children
}
}
如前所述,HTML 元素可以在没有任何子元素或属性的情况下创建,因此我们的函数不需要三个参数,而是需要两个:
createElement (type, opts) {
if (type == null || typeof type !== 'string') {
throw Error('The element type must be a string');
}
return {
type: type
attrs: opts.attr,
children: opts.children
}
}
我们失去了之前引入的默认参数,但可以通过解构将它们恢复。解构允许我们解包对象属性(或数组值),并将它们用作不同的变量。我们可以将其与简写属性结合使用,以简化代码。
createElement (type, { attrs = {}, children = [] }) {
if (type == null || typeof type !== 'string') {
throw Error('The element type must be a string');
}
return {
type,
attrs,
children
}
}
我们的函数可以创建虚拟 DOM 对象,但还没完。之前我们跳过了这段代码type == null || typeof type !== 'string'
。现在我们可以重新回顾一下,并学习一些其他的东西:强制转换。
这里有四件事需要注意:
==
松散相等运算符的行为||
操作员的行为typeof
操作员的行为!==
操作员的行为
我第一次学习 JavaScript 时,看到很多文章建议不要使用松散相等运算符。因为它会产生一些令人惊讶的结果,例如:
1 == '1' // true
null == undefined // true
这很令人惊讶,因为在上面的例子中,我们比较了四种不同原始类型的值:、number
和。上述检查结果为,因为在比较不同类型的值时会执行强制转换。控制这种情况如何发生的规则可以在这里找到。对于我们的具体情况,我们需要知道始终返回 true 的规范状态。此外,通过执行与执行相同的检查,然后对结果取反来工作。您可以在这里阅读有关规则。string
null
undefined
true
==
null == undefined
!==
===
回到我们的函数,它首先type == null || typeof type !== 'string'
检查的是是否传递了null
或undefined
值。如果是true
,||
运算符将返回 的结果typeof type !== 'string'
。这个过程的顺序很重要。||
运算符不返回任何boolean
值。它返回两个表达式之一的值。它首先对 进行boolean
测试type == null
,结果要么是 ,true
要么是false
。如果测试返回true
,则会抛出我们的错误。
但是,如果false
返回的是 ,||
则返回第二个表达式的值,在我们的例子中,该值要么是,要么true
是false
。如果我们的检查结果为 ,type == null || type
并且第一个表达式的结果为false
,则第二个表达式将返回变量 中的任何值type
。typeof
运算符返回一个字符串,指示给定值的类型。我们没有使用它进行type == null
检查,因为typeof null
返回,这是JavaScript 中object
一个臭名昭著的bug。
有了这些新知识,我们可以更深入地审视createElement
并问自己以下问题:
- 我们如何检查第二个参数是否可以被破坏?
- 我们如何检查第二个参数是一个对象?
让我们首先使用不同的参数类型调用我们的函数:
createElement (type, { attrs = {}, children = [] }) {
if (type == null || typeof type !== 'string') {
throw Error('The element type must be a string');
}
return {
type,
attrs,
children
}
}
createElement('div', []); // { type: "div", attrs: {…}, children: Array(0) }
createElement('div', function(){}); // { type: "div", attrs: {…}, children: Array(0) }
createElement('div', false); // { type: "div", attrs: {…}, children: Array(0) }
createElement('div', new Date()); // { type: "div", attrs: {…}, children: Array(0) }
createElement('div', 4); // { type: "div", attrs: {…}, children: Array(0) }
createElement('div', null); // Uncaught TypeError: Cannot destructure property `attrs` of 'undefined' or 'null'
createElement('div', undefined); // Uncaught TypeError: Cannot destructure property `attrs` of 'undefined' or 'null'
现在我们修改一下函数:
createElement (type, opts) {
if (type == null || typeof type !== 'string') {
throw Error('The element type must be a string');
}
if (arguments[1] !== undefined && Object.prototype.toString.call(opts) !== '[object Object]') {
throw Error('The options argument must be an object');
}
const { attrs = {}, children = [] } = opts || {};
return {
type,
attrs,
children
}
}
createElement('div', []); // Uncaught Error: The options argument must be an object
createElement('div', function(){}); // Uncaught Error: The options argument must be an object
createElement('div', false); // Uncaught Error: The options argument must be an object
createElement('div', new Date()); // Uncaught Error: The options argument must be an object
createElement('div', 4); // Uncaught Error: The options argument must be an object
createElement('div', null); // Uncaught Error: The options argument must be an object
createElement('div', undefined); // Uncaught Error: The options argument must be an object
TypeError
我们的第一个函数不符合预期,因为它接受了错误类型的值。当使用null
或 调用时,它还报错undefined
。我们在第二个函数中解决了这个问题,引入了新的检查和新的经验教训:错误类型、剩余参数和this。
null
当我们使用或undefined
作为第二个参数调用函数时,我们看到了这样的消息: Uncaught TypeError: Cannot destructure property 'attrs' of 'undefined' or 'null'
。ATypeError
是一个对象,它表示由于值不是预期类型而导致的错误。它是与ReferenceError
和 一起最常见的错误类型之一SyntaxError
。这就是为什么我们恢复使用对象作为参数的原因,因为在解构函数参数时没有办法防止null
和值。undefined
让我们仔细看看第二次迭代中的检查:
if (arguments[1] !== undefined && Object.prototype.toString.call(opts) !== '[object Object]') {
throw Error('The options argument must be an object');
}
首先要问的问题是:既然有剩余参数,为什么还要使用arguments对象?剩余参数是在ES6中引入的,它允许开发人员以数组的形式表示不定数量的参数,从而更简洁。如果我们使用了它们,我们就可以写出如下代码:
createElement (type, ...args) {
if (type == null || typeof type !== 'string') {
throw Error('The element type must be a string');
}
if (args[0] !== undefined && Object.prototype.toString.call(args[0]) !== '[object Object]') {
throw Error('The options argument must be an object');
}
}
如果我们的函数有很多参数,这段代码就很有用,但因为我们只需要两个参数,所以前一种方法效果更好。第二个函数最令人兴奋的地方在于表达式Object.prototype.toString.call(opts) !== '[object Object]'
。这个表达式是这个问题的答案之一:在 JavaScript 中,如何检查某个东西是否是对象?显而易见的解决方案是首先尝试 ,但正如我们之前讨论过的,由于 JavaScript 中存在使用值typeof opts === "object"
返回的 bug,因此它并不可靠。true
typeof
null
我们选择的解决方案在 ES5 中有效,因为它利用了[[Class]]
内置对象中的 internal 属性。根据 ES5规范,这是一个字符串值,表示规范定义的对象分类。它可以通过方法访问toString
。规范toString
深入解释了的行为,但本质上,它返回一个格式为 的字符串[object [[Class]]]
,其中[[Class]]
是内置对象的名称。
大多数内置函数都会覆盖,toString
因此我们还必须使用call
方法。此方法使用特定this
绑定调用函数。这很重要,因为每当调用函数时,它都会在特定上下文中调用。JavaScript 大师 Kyle Simpson概述了确定 优先顺序的四条规则this
。第二条规则是,当使用call
、apply
或 调用函数时bind
,绑定指向、或this
的第一个参数中指定的对象。因此,使用指向 中任何值的绑定执行。call
apply
bind
Object.prototype.toString.call(opts)
toString
this
opts
在 ES6 中,该[[Class]]
属性已被移除,因此虽然该解决方案仍然有效,但其行为略有不同。规范建议不要使用此解决方案,因此我们可以从Lodash的处理方式中汲取灵感。但是,我们仍会保留它,因为它产生错误结果的风险非常低。
表面上看,我们创建了一个看似简单实用的功能,但正如我们所经历的,整个过程绝非如此。我们可以进入下一个阶段,但这引出了一个问题:这个阶段应该是什么?我们的功能可以进行一些测试,但这需要创建一个开发工作流。现在进行测试是否为时过早?如果我们添加测试,应该使用哪个测试库?在执行任何其他操作之前,先创建一个可行的解决方案不是更好吗?这些都是开发人员每天都要面对的难题,我们将在下一个教程中探讨这些难题(以及答案)。
文章来源:https://dev.to/carlmungazi/learning-javascript-by-building-a-ui-framework-from-scratch-1767