51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

使用现代JavaScript和Web组件构建Web应用程序

浏览器中的JavaScript已经发展。希望利用最新功能的开发人员可以选择无框架且麻烦更少。通常在前端框架中保留的选项(例如基于组件的方法)现在在普通的旧JavaScript中是可行的。

在本文中,我将使用带有作者数据,带有网格和搜索过滤器的UI展示所有最新的JavaScript功能。为简单起见,一旦引入了一种技术,我将继续进行下一种技术,以免弄丢重点。因此,用户界面将具有"添加"选项和下拉搜索过滤器。作者模型将具有三个字段:名称,电子邮件和可选主题。表单验证将主要包括在没有详尽介绍的情况下显示这种无框架的技术。

一度使用方便的语言已经成长为具有许多现代功能,例如代理,导入/导出,可选的链运算符和Web组件。这完全适合Jamstack,因为该应用程序通过HTML和原始JavaScript在客户端上呈现。

我将省略API来专注于应用程序,但是我将指出这种集成可以在应用程序中发生的位置。

入门

该应用程序是一个典型的JavaScript应用程序,具有两个依赖项:http服务器和Bootstrap。该代码仅在浏览器中运行,因此除了托管静态资产的后端外,没有其他后端。该代码已在GitHub上提供[https://github.com/sitepoint-editors/framework-less-web-components],供您使用。

假设您在计算机上安装了最新的Node LTS:

mkdir framework-less-web-componentscd framework-less-web-componentsnpm init

这应该以一个package.json放置依赖关系的文件结束。

要安装两个依赖项:

npm i http-server bootstrap@next --save-exact
  • http-server:HTTP服务器,用于在Jamstack中托管静态资产

  • Bootstrap:一套时尚,功能强大的CSS样式,可简化Web开发

如果您感觉http-server不是依赖关系,而是运行此应用程序的要求,则可以选择通过进行全局安装npm i -g http-server。无论哪种方式,都不会将此依赖项传送到客户端,而只会将静态资产提供给客户端。

打开package.json文件,然后通过"start": "http-server"下设置入口点scripts。继续并通过启动应用程序npm start,它将http://localhost:8080/对浏览器可用。index.html放置在根文件夹中的任何文件都将由HTTP服务器自动托管。您要做的只是刷新页面以获取最新信息。

文件夹结构如下所示:

┳
┣━┓ 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 framework-less-web-componentscd framework-less-web-componentsnpm init

集成Web组件

简而言之,Web组件是自定义HTML元素。他们定义了可以放入标记中的自定义元素,并声明了呈现组件的回调方法。

以下是自定义Web组件的简要介绍:

class HelloWorldComponent extends HTMLElement {
  connectedCallback() { // callback method
    this.innerHTML = 'Hello, World!'
  }}// Define the custom elementwindow.customElements.define('hello-world', HelloWorldComponent)// The markup can use this custom web component via:// <hello-world></hello-world>

如果您觉得需要对Web组件进行更温和的介绍,请查看MDN文章[https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements]。刚开始,他们可能会觉得很神奇,但是对回调方法的很好理解使这一点很清楚。

主要的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中的导入/导出的原因。在用标签定义的HTML元素,使Web组件。我已经打破了应用分为三个主要组成部分:,,和。由于尚未在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。最后,appendChildcontent进入DOM。如果您遇到任何无法呈现Web组件的问题,请确保首先检查克隆的内容是否已附加到DOM。

接下来,定义AuthorGrid.jsWeb组件。这将遵循类似的模式,并对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())
  }}

我用定义了主要this.table元素querySelector。由于这是一个类,因此可以通过使用保留对目标元素的良好引用thisupdateContent当没有作者在网格中显示时,该方法主要破坏主表。在可选的链接运营商(?.)和空合并采取设置照顾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键时,它将重置表单的状态。

通过addEventListenerconnectedCallback方法内部附加此代码来关联事件。可以将其添加到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.formDOM中的元素。该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通过通过classListWeb API添加/删除CSS类名称使此操作变得容易一些。

AuthorForm.js组件内部,console.log在Enter键事件处理程序中找到,使用" 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}

在普通JavaScript中,调用checkValidity使用内置的HTML验证程序,因为我使用标记了输入元素type="email"。要检查必填字段,可以通过基本的真实性检查来解决问题i.value。该classList网络API添加或删除CSS类名,所以引导的造型可以做自己的工作。

现在,继续尝试该应用。现在将尝试输入无效数据的标记,并且有效数据现在将重置表单。

Observables

这种方法的时间(对于我的素食者来说是土豆),因为Web组件和事件处理程序只能带我走那么远。为了使该应用程序处于状态驱动状态,我需要一种方法来跟踪对UI状态的更改。事实证明,可观察对象非常适合此操作,因为当状态发生变化时,它们可以触发对UI的更新。将可观察对象视为子/发布模型,订阅者在其中监听更改,发布者触发在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}

乍一看,这似乎很可怕,但是它在做两件事:劫持设置器以捕获突变,以及添加侦听器。在ES6 +中,Proxy该类启用环绕initialState对象的代理。这样可以拦截诸如此set方法之类的基本操作,该操作在对象发生更改时执行。返回true设置器将使JavaScript中的内部机制知道该突变已成功。该Proxy设置将诸如陷阱处理程序对象set得到定义。因为我只关心状态对象的突变,所以set有一个陷阱。所有其他功能(例如读取)直接转发到原始状态对象。

侦听器会保留一个已订阅的回调的列表,这些列表希望收到有关突变的通知。在添加侦听器后,该回调将执行一次,并返回侦听回调以供将来参考。

freezecloneDeep功能到位,以防止潜在的状态对象的任何其他突变。由于数据仅在一个方向上移动,因此这使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})

有两个可用的对象:代理observableStateactionswith突变。所述INITIAL_STATE自举与初始数据的应用程序。这就是设置初始零配置状态的原因。动作突变采用可观察的状态,并通过对observableState对象进行更改来触发所有侦听器的更新。

由于applicationContext尚未通过网络将变体连接到Web组件,因此UI不会跟踪任何更改。Web组件将需要HTML属性来变异和显示状态数据。这就是接下来的事情。

Observed Attributes

对于Web组件,可以通过Web API属性跟踪状态的变化。这些getAttributesetAttributehasAttribute。有了这个工具库,将UI状态保持在DOM中会更有效。

破解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和类期望的内容之间进行映射,这就是驼峰式的情况。

connectAttributesWeb组件中的方法添加了自己的侦听器以跟踪状态突变。attributeChangedCallback当属性更改,并且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 declarationstatic get observedAttributes() {
  return [
    'current-filter'
  ]}// In the Enter event handler, right above resetFormthis.addAuthor({
  name: inputs[0].value,
  email: inputs[1].value,
  topic: select.value === 'Topic' ? '' : select.value})// In the select event handler, rigth below console.logthis.changeFilter(e.target.value)// At the very end of the connectedCallback methodsuper.connectAttributes()// These helpers method go at the bottom of the classaddAuthor(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并连接可观察属性:

// This goes at top, right below the class declarationstatic get observedAttributes() {
  return [
    'authors',
    'current-filter'
  ]}// At the very end of the connectedCallback methodsuper.connectAttributes()// This helper method can go right above updateContentgetAuthorRow(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 endthis.authors
  .map(a => this.getAuthorRow(a))
  .forEach(e => this.table
    .querySelector('tbody')
    .appendChild(e))

每个Web组件都可以跟踪不同的属性,具体取决于在UI中呈现的内容。这是分离组件的好方法,因为它只处理自己的状态数据。

继续并在浏览器中旋转一下。破解开发人员工具并检查HTML。您将current-filter在Web组件的根目录中看到DOM中设置的属性,例如。单击并按时Enter,请注意,该应用程序会自动跟踪DOM中状态的突变。

陷阱

对于piècederésistance,请确保将开发人员工具保持打开状态,然后转到JavaScript Debugger并找到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 classconst equalDeep = (x, y) => JSON.stringify(x) === JSON.stringify(y)// Inside the authors setterif (this.constructor.observedAttributes.includes('authors')
  && !equalDeep(this.authors, value)) {// Inside the currentFilter setterif (this.constructor.observedAttributes.includes('current-filter')
  && this.currentFilter !== value) {

这增加了一层防御性编程来检测属性更改。当Web组件意识到不需要更新UI时,它将跳过设置属性。

现在返回带有断点的浏览器,更新状态应该updateContent只命中一次。

最终演示

这是带有可观察对象和Web组件的应用程序的外观:

123.jpg

赞(0)
未经允许不得转载:工具盒子 » 使用现代JavaScript和Web组件构建Web应用程序