浏览器中的 JavaScript 已经发展。想要利用最新功能的开发人员可以选择减少框架,减少麻烦。通常保留给前端框架的选项,例如基于组件的方法,现在在普通的旧 JavaScript 中是可行的。
在本次拍摄中,我将展示所有最新的 JavaScript 功能,使用带有网格和搜索过滤器的作者数据的 UI。为简单起见,一旦介绍了一种技术,我将继续介绍下一种技术,以免强调这一点。出于这个原因,用户界面将有一个添加选项和一个下拉搜索过滤器。作者模型将具有三个字段:姓名、电子邮件和可选主题。将包含表单验证,主要是为了展示这种无框架的技术,而不是彻底。
曾经勇敢的语言已经成长为具有许多现代功能,例如代理、导入/导出、可选链运算符和 Web 组件。这非常适合Jamstack,因为应用程序通过 HTML 和 vanilla JavaScript 在客户端呈现。
我将省略 API 以专注于应用程序,但我会指出这种集成可以在应用程序中发生的位置。
入门
该应用程序是一个典型的 JavaScript 应用程序,具有两个依赖项:http-server 和 Bootstrap。代码只会在浏览器中运行,所以除了托管静态资产之外没有其他后端。代码在 GitHub 上供您使用。
假设您在机器上安装了最新的 Node LTS:
mkdir framework-less-web-components
cd framework-less-web-components
npm init
package.json 这应该以放置依赖项的单个文件结束。
要安装两个依赖项:
npm i http-server bootstrap@next --save-exact
如果您觉得http-server 不是依赖项,而是该应用程序运行的要求,则可以选择通过npm i -g http-server .?无论哪种方式,此依赖项都不会发送给客户端,而只会为客户端提供静态资产。
打开文件并通过在 下package.json 设置入口点。继续并通过 启动应用程序,这将使浏览器可用。放在根文件夹中的任何文件都会自动由 HTTP 服务器托管。您所做的只是刷新页面以获取最新信息。"start": "http-server" scripts npm start http://localhost:8080/ index.html
文件夹结构如下所示:
┳
┣━┓ components
┃ ┣━━ App.js
┃ ┣━━ AuthorForm.js
┃ ┣━━ AuthorGrid.js
┃ ┗━━ ObservableElement.js
┣━┓ model
┃ ┣━━ actions.js
┃ ┗━━ observable.js
┣━━ index.html
┣━━ index.js
┗━━ package.json
这是每个文件夹的用途:
components App.js : 带有继承自的自定义元素的 HTML Web 组件ObservableElement.js model : 监听 UI 状态变化的应用状态和突变index.html :可以在任何地方托管的主要静态资产文件
要在每个文件夹中创建文件夹和文件,请运行以下命令:
mkdir components model
touch components/App.js components/AuthorForm.js components/AuthorGrid.js components/ObservableElement.js model/actions.js model/observable.js index.html index.js
集成 Web 组件
简而言之,Web 组件是自定义 HTML 元素。他们定义了可以放在标记中的自定义元素,并声明了一个呈现组件的回调方法。
以下是自定义 Web 组件的简要说明:
class HelloWorldComponent extends HTMLElement {
connectedCallback() { // callback method
this.innerHTML = 'Hello, World!'
}
}
// Define the custom element
window.customElements.define('hello-world', HelloWorldComponent)
// The markup can use this custom web component via:
// <hello-world></hello-world>
如果您觉得需要更温和地介绍 Web 组件,请查看MDN 文章。起初,他们可能会觉得很神奇,但是对回调方法的良好掌握使这一点变得非常清楚。
主index.html 静态页面声明 HTML Web 组件。我将使用 Bootstrap 来设置 HTML 元素的样式,并引入index.js 成为应用程序主要入口点和 JavaScript 网关的资产。
胸围打开index.html 文件并将其放置到位:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
<title>Framework-less Components</title>
</head>
<body>
<template id="html-app">
<div class="container">
<h1>Authors</h1>
<author-form></author-form>
<author-grid></author-grid>
<footer class="fixed-bottom small">
<p class="text-center mb-0">
Hit Enter to add an author entry
</p>
<p class="text-center small">
Created with ? By C R
</p>
</footer>
</div>
</template>
<template id="author-form">
<form>
<div class="row mt-4">
<div class="col">
<input type="text" class="form-control" placeholder="Name" aria-label="Name">
</div>
<div class="col">
<input type="email" class="form-control" placeholder="Email" aria-label="Email">
</div>
<div class="col">
<select class="form-select" aria-label="Topic">
<option>Topic</option>
<option>JavaScript</option>
<option>HTMLElement</option>
<option>ES7+</option>
</select>
</div>
<div class="col">
<select class="form-select search" aria-label="Search">
<option>Search by</option>
<option>All</option>
<option>JavaScript</option>
<option>HTMLElement</option>
<option>ES7+</option>
</select>
</div>
</div>
</form>
</template>
<template id="author-grid">
<table class="table mt-4">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Topic</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</template>
<template id="author-row">
<tr>
<td></td>
<td></td>
<td></td>
</tr>
</template>
<nav class="navbar navbar-expand-lg navbar-light bg-dark">
<div class="container-fluid">
<a class="navbar-brand text-light" href="/">
Framework-less Components with Observables
</a>
</div>
</nav>
<html-app></html-app>
<script type="module" src="index.js"></script>
</body>
</html>
密切注意属性设置为的script 标记。这就是在浏览器中解锁原生 JavaScript 中的导入/导出的原因。带有的标签定义了启用 Web 组件的 HTML 元素。我将应用程序分为三个主要组件:、和。由于尚未在 JavaScript 中定义任何内容,因此应用程序将呈现导航栏而无需任何自定义 HTML 标记。type module template id html-app author-form author-grid
要轻松开始,请将其放入ObservableElement.js .?它是所有作者组件的父元素:
export default class ObservableElement extends HTMLElement {
}
然后,html-app 在 中定义组件App.js :
export default class App extends HTMLElement {
connectedCallback() {
this.template = document
.getElementById('html-app')
window.requestAnimationFrame(() => {
const content = this.template
.content
.firstElementChild
.cloneNode(true)
this.appendChild(content)
})
}
}
注意export default 声明 JavaScript 类的使用。这是我module 在引用主脚本文件时通过类型启用的功能。要使用 Web 组件,请继承HTMLElement 并定义connectedCallback 类方法。浏览器负责其余的工作。我requestAnimationFrame 用来在浏览器中下一次重绘之前渲染主模板。
这是您将在 Web 组件中看到的常用技术。首先,通过元素 ID 获取模板。然后,通过 克隆模板cloneNode 。最后,appendChild 新content 进入 DOM。如果您遇到任何 Web 组件无法呈现的问题,请务必先检查克隆的内容是否已附加到 DOM。
接下来,定义AuthorGrid.js Web 组件。这将遵循类似的模式并稍微操作 DOM:
import ObservableElement from './ObservableElement.js'
export default class AuthorGrid extends ObservableElement {
connectedCallback() {
this.template = document
.getElementById('author-grid')
this.rowTemplate = document
.getElementById('author-row')
const content = this.template
.content
.firstElementChild
.cloneNode(true)
this.appendChild(content)
this.table = this.querySelector('table')
this.updateContent()
}
updateContent() {
this.table.style.display =
(this.authors?.length ?? 0) === 0
? 'none'
: ''
this.table
.querySelectorAll('tbody tr')
.forEach(r => r.remove())
}
}
我用 a 定义了主要this.table 元素querySelector 。因为这是一个类,所以可以通过使用来保持对目标元素的良好引用this 。updateContent 当网格中没有要显示的作者时,该方法主要破坏主表。可选的链接运算符(??. ) 和空值合并负责将display 样式设置为无。
看一下该import 语句,因为它在文件名中引入了具有完全限定扩展名的依赖项。如果您习惯于 Node 开发,这就是它与浏览器实现不同的地方,后者遵循标准,这确实需要像.js .?向我学习并确保在浏览器中工作时添加文件扩展名。
接下来,该AuthorForm.js 组件有两个主要部分:呈现 HTML 并将元素事件连接到表单。
要呈现表单,请打开AuthorForm.js :
import ObservableElement from './ObservableElement.js'
export default class AuthorForm extends ObservableElement {
connectedCallback() {
this.template = document
.getElementById('author-form')
const content = this.template
.content
.firstElementChild
.cloneNode(true)
this.appendChild(content)
this.form = this.querySelector('form')
this.form.querySelector('input').focus()
}
resetForm(inputs) {
inputs.forEach(i => {
i.value = ''
i.classList.remove('is-valid')
})
inputs[0].focus()
}
}
引导用户在focus 表单中可用的第一个输入元素上开始输入。确保在之后放置任何 DOM 选择器,appendChild 否则此技术将不起作用。现在resetForm 不使用,但会在用户按 Enter 时重置表单的状态。
addEventListener 通过将此代码附加到connectedCallback 方法中来连接事件。这可以添加到connectedCallback 方法的最后:
this.form
.addEventListener('keypress', e => {
if (e.key === 'Enter') {
const inputs = this.form.querySelectorAll('input')
const select = this.form.querySelector('select')
console.log('Pressed Enter: ' +
inputs[0].value + '|' +
inputs[1].value + '|' +
(select.value === 'Topic' ? '' : select.value))
this.resetForm(inputs)
}
})
this.form
.addEventListener('change', e => {
if (e.target.matches('select.search')
&& e.target.value !== 'Search by') {
console.log('Filter by: ' + e.target.value)
}
})
这些是附加到this.form DOM 中元素的典型事件侦听器。该change 事件使用事件委托来侦听表单中的所有更改事件,但仅针对select.search 元素。这是将单个事件委托给父元素中尽可能多的目标元素的有效方法。有了这个,在表单中输入任何内容并按 Enter 会将表单重置为零状态。
要让这些 Web 组件在客户端上呈现,请打开index.js 并将其放入:
import AuthorForm from './components/AuthorForm.js'
import AuthorGrid from './components/AuthorGrid.js'
import App from './components/App.js'
window.customElements.define('author-form', AuthorForm)
window.customElements.define('author-grid', AuthorGrid)
window.customElements.define('html-app', App)
现在请随意在浏览器中刷新页面并使用 UI。打开您的开发人员工具并在您单击并输入表单时查看控制台消息。按下该Tab键应该可以帮助您在 HTML 文档中的输入元素之间导航。
通过使用表单,您可能会注意到当姓名和电子邮件都需要时,它会接受任意输入,并且主题是可选的。无框架的方法可以是 HTML 验证和一些 JavaScript 的组合。幸运的是,Bootstrap 通过classList Web API 添加/删除 CSS 类名称使这变得有点容易。
在AuthorForm.js 组件内部,console.log 在Enterkey 事件处理程序中找到 ,查找带有“Pressed Enter”的日志,并将其放在它的正上方:
if (!this.isValid(inputs)) return
然后,在 中定义isValid 类方法AuthorForm 。这可能会超出resetForm 方法:
isValid(inputs) {
let isInvalid = false
inputs.forEach(i => {
if (i.value && i.checkValidity()) {
i.classList.remove('is-invalid')
i.classList.add('is-valid')
} else {
i.classList.remove('is-valid')
i.classList.add('is-invalid')
isInvalid = true
}
})
return !isInvalid
}
在 vanilla JavaScript 中,调用checkValidity 使用内置的HTML 验证器,因为我用type="email" .?要检查必填字段,基本的真实性检查通过i.value .?Web API 添加或删除 CSS 类名称,因此classList Bootstrap 样式可以完成其工作。
现在,继续尝试该应用程序。尝试输入无效数据现在会被标记,有效数据现在会重置表单。
可观察的
这种方法的肉(或我的素食朋友的土豆)是时候了,因为 Web 组件和事件处理程序只能带我到此为止。为了使这个应用程序状态驱动,我需要一种方法来跟踪 UI 状态的变化。事实证明,observables 非常适合这一点,因为它们可以在状态发生变化时触发 UI 更新。将 observables 视为 sub/pub 模型,订阅者在其中侦听更改,并且发布者触发 UI 状态中发生的更改。这简化了在没有任何框架的情况下构建复杂且令人兴奋的 UI 所需的推拉代码量。
打开obserable.js 下面的文件model 并将其放入:
const cloneDeep = x => JSON.parse(JSON.stringify(x))
const freeze = state => Object.freeze(cloneDeep(state))
export default initialState => {
let listeners = []
const proxy = new Proxy(cloneDeep(initialState), {
set: (target, name, value) => {
target[name] = value
listeners.forEach(l => l(freeze(proxy)))
return true
}
})
proxy.addChangeListener = cb => {
listeners.push(cb)
cb(freeze(proxy))
return () =>
listeners = listeners.filter(el => el !== cb)
}
return proxy
}
乍一看这可能看起来很可怕,但它做了两件事:劫持 setter 以捕获突变,以及添加侦听器。在 ES6+ 中,Proxy 该类启用了一个环绕initialState 对象的代理。这可以拦截像这个set 方法这样的基本操作,它在对象发生变化时执行。true 在 setter 中返回让 JavaScript 中的内部机制知道突变成功。Proxy 设置一个处理程序对象,其中定义了诸如此类的陷阱set 。因为我只关心状态对象的突变set ,所以有一个陷阱。所有其他功能,例如读取,都直接转发到原始状态对象。
侦听器保留一个想要收到突变通知的订阅回调列表。回调在添加监听器后执行一次,并返回监听回调以供将来参考。
freeze 和函数被放置在适当的cloneDeep 位置以防止底层状态对象的任何进一步变化。这使得 UI 状态更加可预测并且在某种程度上是无状态的,因为数据只在一个方向上移动。
现在,转到actions.js 文件并将其放置到位:
export default state => {
const addAuthor = author => {
if (!author) return
state.authors = [...state.authors, {
...author
}]
}
const changeFilter = currentFilter => {
state.currentFilter = currentFilter
}
return {
addAuthor,
changeFilter
}
}
这是一个可测试的 JavaScript 对象,它对状态执行实际的突变。为简洁起见,我将放弃编写单元测试,但将其留给读者作为练习。
要从 Web 组件触发突变,它们需要在全局window.applicationContext 对象上注册。这使得这个带有突变的状态对象可用于应用程序的其余部分。
打开主index.js 文件并将其添加到我注册自定义元素的正上方:
import observableFactory from './model/observable.js'
import actionsFactory from './model/actions.js'
const INITIAL_STATE = {
authors: [],
currentFilter: 'All'
}
const observableState = observableFactory(INITIAL_STATE)
const actions = actionsFactory(observableState)
window.applicationContext = Object.freeze({
observableState,
actions
})
有两个对象可用:代理observableState 和actions with 突变。使用INITIAL_STATE 初始数据引导应用程序。这就是设置初始零配置状态的原因。observableState 动作突变采用可观察状态并通过对对象进行更改来为所有侦听器触发更新。
因为突变还没有通过 web 组件连接到 web 组件applicationContext ,所以 UI 不会跟踪任何更改。Web 组件将需要 HTML 属性来改变和显示状态数据。这就是接下来的事情。
观察到的属性
对于 Web 组件,可以通过属性 Web API 跟踪状态的变化。它们是getAttribute 、setAttribute 和hasAttribute 。有了这个武器库,在 DOM 中持久化 UI 状态会更有效。
破解ObservableElement.js 并取出,用以下代码替换它:
export default class ObservableElement extends HTMLElement {
get authors() {
if (!this.hasAttribute('authors')) return []
return JSON.parse(this.getAttribute('authors'))
}
set authors(value) {
if (this.constructor
.observedAttributes
.includes('authors')) {
this.setAttribute('authors', JSON.stringify(value))
}
}
get currentFilter() {
if (!this.hasAttribute('current-filter')) return 'All'
return this.getAttribute('current-filter')
}
set currentFilter(value) {
if (this.constructor
.observedAttributes
.includes('current-filter')) {
this.setAttribute('current-filter', value)
}
}
connectAttributes () {
window
.applicationContext
.observableState
.addChangeListener(state => {
this.authors = state.authors
this.currentFilter = state.currentFilter
})
}
attributeChangedCallback () {
this.updateContent()
}
}
current-filter 我特意在属性中使用了蛇形外壳。这是因为属性 Web API 仅支持小写名称。getter/setter 在这个 Web API 和类所期望的之间进行映射,这是驼峰式的。
Web 组件中的connectAttributes 方法添加了自己的监听器来跟踪状态突变。当属性更改时,会有一个attributeChangedCallback available 触发,并且 Web 组件会更新 DOM 中的属性。此回调还调用updateContent 以告诉 Web 组件更新 UI。ES6+ getter/setter 声明了与状态对象相同的属性。this.authors 例如,这就是使Web 组件可以访问的原因。
注意使用constructor.observedAttributes .?这是我现在可以声明的自定义静态字段,因此父类ObservableElement 可以跟踪 Web 组件关心的属性。有了这个,我可以选择状态模型的哪一部分与 Web 组件相关。
我将借此机会充实实现的其余部分,以通过每个 Web 组件中的可观察对象来跟踪和更改状态。当状态发生变化时,这就是让 UI “活跃起来”的原因。
返回AuthorForm.js 并进行这些更改。代码注释会告诉你把它放在哪里(或者你可以查阅repo):
// This goes at top, right below the class declaration
static get observedAttributes() {
return [
'current-filter'
]
}
// In the Enter event handler, right above resetForm
this.addAuthor({
name: inputs[0].value,
email: inputs[1].value,
topic: select.value === 'Topic' ? '' : select.value
})
// In the select event handler, rigth below console.log
this.changeFilter(e.target.value)
// At the very end of the connectedCallback method
super.connectAttributes()
// These helpers method go at the bottom of the class
addAuthor(author) {
window
.applicationContext
.actions
.addAuthor(author)
}
changeFilter(filter) {
window
.applicationContext
.actions
.changeFilter(filter)
}
updateContent() {
// Capture state mutation to synchronize the search filter
// with the dropdown for a nice effect, and reset the form
if (this.currentFilter !== 'All') {
this.form.querySelector('select').value = this.currentFilter
}
this.resetForm(this.form.querySelectorAll('input'))
}
在 Jamstack 中,您可能需要调用后端 API 来持久化数据。我建议对这些类型的调用使用辅助方法。一旦持久化状态从 API 返回,它就可以在应用程序中发生变化。
最后,找到AuthorGrid.js 并连接 observable 属性(最终文件在这里):
// This goes at top, right below the class declaration
static get observedAttributes() {
return [
'authors',
'current-filter'
]
}
// At the very end of the connectedCallback method
super.connectAttributes()
// This helper method can go right above updateContent
getAuthorRow(author) {
const {
name,
email,
topic
} = author
const element = this.rowTemplate
.content
.firstElementChild
.cloneNode(true)
const columns = element.querySelectorAll('td')
columns[0].textContent = name
columns[1].textContent = email
columns[2].textContent = topic
if (this.currentFilter !== 'All'
&& topic !== this.currentFilter) {
element.style.display = 'none'
}
return element
}
// Inside updateContent, at the very end
this.authors
.map(a => this.getAuthorRow(a))
.forEach(e => this.table
.querySelector('tbody')
.appendChild(e))
每个 Web 组件都可以跟踪不同的属性,具体取决于在 UI 中呈现的内容。这是一种很好的分离组件的干净方式,因为它只处理自己的状态数据。
继续并在浏览器中试一试。破解打开开发者工具并检查 HTML。您将看到在 DOM 中设置的属性,例如current-filter ,位于 Web 组件的根目录。当您单击并按下Enter时,请注意应用程序会自动跟踪 DOM 中状态的变化。
陷阱
为了解决这个问题,请务必打开开发人员工具,转到 JavaScript 调试器并找到AuthorGrid.js .?然后,在updateContent .?选择一个搜索过滤器。注意到浏览器不止一次地点击了这段代码吗?这意味着更新 UI 的代码不会运行一次,而是每次状态发生变化时运行。
这是因为此代码位于ObservableElement :
window
.applicationContext
.observableState
.addChangeListener(state => {
this.authors = state.authors
this.currentFilter = state.currentFilter
})
目前,当状态发生变化时,恰好有两个监听器会触发。如果 Web 组件跟踪多个状态属性,例如this.authors ,这会触发更多的 UI 更新。这会导致 UI 更新效率低下,并可能导致延迟足够的侦听器和对 DOM 的更改。
要解决这个问题,请打开ObservableElement.js 并返回 HTML 属性设置器:
// This can go outside the observable element class
const equalDeep = (x, y) => JSON.stringify(x) === JSON.stringify(y)
// Inside the authors setter
if (this.constructor.observedAttributes.includes('authors')
&& !equalDeep(this.authors, value)) {
// Inside the currentFilter setter
if (this.constructor.observedAttributes.includes('current-filter')
&& this.currentFilter !== value) {
这增加了一层防御性编程来检测属性变化。当 Web 组件意识到它不需要更新 UI 时,它会跳过设置属性。
现在回到带有断点的浏览器,更新状态应该updateContent 只命中一次。
最终演示
这是带有可观察对象和 Web 组件的应用程序的外观:
别忘了,你可以在 GitHub 上找到完整的代码。
|