Componentes web en un proyecto real


¡Hola a todos! Mi nombre es Arthur, trabajo como desarrollador frontend en Exness. No hace mucho tiempo, cambiamos uno de nuestros proyectos a componentes web. Te diré con qué problemas tuvimos que lidiar, y cuántos de los conceptos a los que estamos acostumbrados cuando trabajamos con frameworks se transfieren fácilmente a los componentes web.


Mirando hacia el futuro, diré que el proyecto implementado pasó con éxito las pruebas en nuestra amplia audiencia, y el tamaño del paquete y el tiempo de carga se redujeron significativamente.


Supongo que ya tiene una comprensión básica de la tecnología, pero incluso sin ella quedará claro que es bastante conveniente trabajar con componentes web y organizar la arquitectura del proyecto.


¿Por qué componentes web?


, ; ui- . - , html+js, . , . , Svelte , , Preact. , , .


?


, , - . . , (~2kB), , — , . IE11 Edge. , IE , Edge . UC Browser QQ Browser, .


:


  • <slot></slot> IE11 & Edge ;
  • html-. .

LitElement


« boilerplate- ! , HTMLElement » — , -. , , — LitElement (~7kb). lit-html .


, LitElement - , .


LitElement — , - « ». 7kB, LitElement API. boilerplate-.


-


, lit-html ~3.5kB . DOM- - : , . , ( ):


import { html, render } from 'lit-html'

const ui = data => html`...${data}...`

render(ui('Hello!'), document.body)

:


const myHeader = html`<h1>Header</h1>`
const myPage = html`
  ${myHeader}
  <div>Here's my main page.</div>
`

:


const defineFxComponent = (tagName, FxComponent, Parent = LitElement) => {
  const Component = class extends Parent {
    render() {
      return FxComponent(this.data)
    }
  }
  customElements.define(tagName, Component)
}

defineFxComponent('custom-ui', ui)

render(html`<custom-ui .data="Hello!"></custom-ui>`, document.body)

, , , , lit-html. . , , .


svg


, ( API) — svg``. , , html`` . , TemplateResult ( html``) — , . svg`` SVGTemplateResult .



— , , . lit-html DOM , Part. Part , , , — .


, Part:


  • (NodePart);
  • (AttributePart);
  • (BooleanAttributePart);
  • (PropertyPart);
  • (EventPart).

.


value — , . setValue(). DOM , , commit() ( ).


( NodePart — ), :


import { directive } from 'lit-html'

const renderCounter = directive(() => part =>
  part.setValue(part.value === undefined ? 0 : part.value + 1)
)

Lit-html . , , , , html .


state . .


. . .


shady-render


lit-html Shadow DOM, . lit-html shady-render, .



HOC — , React Vue. , - -. - , HOC , , .



redux, . , store redux. mapStateToProps ( , HOC, redux), , DOM, redux. DOM .


import { bindActionCreators } from 'redux'

export default store => (mapStateToProps, mapDispatchToProps) => Component =>
  class Connect extends Component {
    constructor(props) {
      super(props)
      this._getPropsFromStore = this._getPropsFromStore.bind(this)
      this._getInheritChainProps = this._getInheritChainProps.bind(this)

      //  mapStateToProps
      this._inheritChainProps = (this._inheritChainProps || []).concat(
        mapStateToProps
      )
    }

    //      store
    _getPropsFromStore(mapStateToProps) {
      if (!mapStateToProps) return
      const state = store.getState()
      const props = mapStateToProps(state)

      for (const prop in props) {
        this[prop] = props[prop]
      }
    }

    // Callback     store,    mapStateToProps   
    _getInheritChainProps() {
      this._inheritChainProps.forEach(i => this._getPropsFromStore(i))
    }

    connectedCallback() {
      this._getPropsFromStore(mapStateToProps)

      this._unsubscriber = store.subscribe(this._getInheritChainProps)

      if (mapDispatchToProps) {
        const dispatchers =
          typeof mapDispatchToProps === 'function'
            ? mapDispatchToProps(store.dispatch)
            : mapDispatchToProps
        for (const dispatcher in dispatchers) {
          typeof mapDispatchToProps === 'function'
            ? (this[dispatcher] = dispatchers[dispatcher])
            : (this[dispatcher] = bindActionCreators(
                dispatchers[dispatcher],
                store.dispatch,
                () => store.getState()
              ))
        }
      }

      super.connectedCallback()
    }

    disconnectedCallback() {
      //     store
      this._unsubscriber()
      super.disconnectedCallback()
    }
  }

store , :


// store.js
import { createStore } from 'redux'
import makeConnect from 'lite-redux'
import reducer from './reducer'

const store = createStore(reducer)

export default store

//   
export const connect = makeConnect(store)

// Component.js
import { connect } from './store'

class Component extends WhatEver {
  /* ... */
}

export default connect(mapStateToProps, mapDispatchToProps)(Component)


. . , : get observedAttributes() - get properties() LitElement. , :


const withPassword = Component =>
  class PasswordInput extends Component {
    static get properties() {
      return {
        //   super.properties    type
        ...super.properties,
        addonIcon: { type: String }
      }
    }

    constructor(props) {
      super(props)
      this.type = 'password'
      this.addonIcon = 'invisible'
    }

    setType(e) {
      this.type = this.type === 'text' ? 'password' : 'text'
      this.addonIcon = this.type === 'password' ? 'invisible' : 'visible'
    }

    render() {
      return html`
        <div class="with-addon">
          <!--    -->
          ${super.render()}
          <div @click=${this.setType}>
            <custom-icon icon=${this.addonIcon}></custom-icon>
          </div>
        </div>
      `
    }
  }

customElements.define('password-input', withPassword(TextInput))

...super.properties get properties(), . super.render() render, .


HOC :


  • , ;
  • , - , - , HOC', ;
  • , HOC.

Shadow DOM


, ui- . , : (values, errors, touched), , submit, reset.


, , . , , , : -, <form> . ...


1. - Shadow DOM


, — - , Shadow DOM , HOC , . .


, <form> HTMLFormElement (, submit Enter), . , , <form>, — <form> .


. render- React:


//  
import { LitElement, html } from 'lit-element'

class LiteForm extends LitElement {
  /* ...  ... */

  render() {
    return html`<form @submit=${this.handleSubmit} method=${this.method}>
      ${this.formTemplate(this)}
    </form>`
  }
}

customElements.define('lite-form', LiteForm)

//  
import { html, render } from 'lit-element'

const formTemplate = ({ values, handleBlur, handleChange, ...props }) =>
  html`<input
      .value=${values.firstName}
      @input=${handleChange}
      @blur=${handleBlur}
    />
    <input
      .value=${values.lastName}
      @input=${handleChange}
      @blur=${handleBlur}
    />
    <button type="submit">Submit</button>`

const MyForm = html`<lite-form
  method="POST"
  .formTemplate=${formTemplate}
  .onSubmit=${{/*...*/}}
  .initialValues=${{/*...*/}}
  .validationSchema=${{/*...*/}}
></lite-form>`

render(html`${MyForm}`, document.getElementById('root'))

, . . , withField withError.


, HOC' . ( , — , ), , :


//   IS_LITE_FORM —    ,      
const getFormClass = element => {
  const form = element.closest(`[${IS_LITE_FORM}]`)
  if (form) return form

  const host = element.getRootNode().host
  if (!host) throw new Error('Lite-form not found')
  return host[IS_LITE_FORM] ? host : getFormClass(host)
}

: , . getRootNode, Shadow DOM — .


withField :


const formTemplate = props =>
  html`<custom-input name="firstName"></custom-input>
    <custom-input name="lastName"></custom-input>
    <button type="submit">Submit</button>`

, , … , Shadow DOM, .


Shadow DOM :host :host-context


CSS . Shadow DOM, , . , , , :


:host([rtl]) {
  text-align: right;
}

:host-context(body[dir='rtl']) .text {
  text-align: right;
}

:host , . , rtl.


:host-context , Shadow DOM, . .text dir, body.


Shadow DOM


Shadow DOM : target, -. DOM, , target , .


Shadow DOM composed. Shadow DOM, composed bubbles true. Shadow DOM.


Shadow DOM


-, - (-, , ) Shadow DOM. . ( , ) .


, , , , Shadow DOM . , , , Shadow DOM. , Shadow DOM.


-, - querySelector, Shadow DOM. , , Google Tag Manager document.querySelector(...) .shadowRoot.querySelector(...) .shadowRoot.querySelector(...) .shadowRoot.querySelector(...)


Shadow DOM . LitElement :


createRenderRoot() {
  return this
}

Shadow DOM blur . withField, . , .


2.


Shadow DOM HTMLFormElement <form> — , , :


//  
class LiteForm extends HTMLFormElement {
  connectedCallback() {
    this.addEventListener('submit', this.handleSubmit)
  }

  disconnectedCallback() {
    this.removeEventListener('submit', this.handleSubmit)
  }

  /* ...  ... */
}

customElements.define('lite-form', LiteForm, { extends: 'form' })

, :


//  
const MyForm = html`<form
  method="POST"
  is="lite-form"
  .onSubmit=${{...}}
  .initialValues=${{...}}
  .validationSchema=${{...}}
>
  <custom-input name="firstName"></custom-input>
  <custom-input name="lastName"></custom-input>
  <button type="submit">Submit</button>
</form>`

render(html`${MyForm}`, document.getElementById('root'))

customElements.define is , , «» . customElements.define, , .


. : Safari , . iOS ( Chrome, Firefox .). , Apple , 13 Safari iOS . , , Safari iOS .


3.


, , . :


//    
export const withForm = ({
  onSubmit,
  initialValues,
  validationSchema,
  ...config
} = {}) => Component =>
  class LiteForm extends Component {
    connectedCallback() {
      this._onSubmit = (onSubmit || this.onSubmit || function () {}).bind(this)
      this._initialValues = initialValues || this.initialValues || {}
      this._validationSchema = validationSchema || this.validationSchema || {}
      /* ... */
      super.connectedCallback && super.connectedCallback()
    }

    /* ...  ... */
  }

connectedCallback() (onSubmit, initialValues, validationSchema, .) , withForm(), . , , , . , :


//       :
//  -  LitElement
import { withForm } from 'lite-form'

class LiteForm extends LitElement {
  render() {
    return html`<form @submit=${this.handleSubmit} method=${this.method}>
      ${this.formRender(this)}
    </form>`
  }
}

customElements.define('lite-form', withForm(LiteForm))

//       :
//   
import { withForm } from 'lite-form'

class LiteForm extends HTMLFormElement {
  connectedCallback() {
    this.addEventListener('submit', this.handleSubmit)
  }

  disconnectedCallback() {
    this.removeEventListener('submit', this.handleSubmit)
  }
}

customElements.define('lite-form', withForm(LiteForm), { extends: 'form' })

, , a withForm() , , HOC:


//  
import { withForm } from 'lite-form'

class UserForm extends LitElement {
  render() {
    return html`
      <form method="POST" @submit=${this.handleSubmit}>
        <custom-input name="firstName"></custom-input>
        <custom-input name="lastName"></custom-input>
        <button type="submit">Submit</button>
      </form>
    `
  }
}

const enhance = withForm({
  initialValues: {/*...*/},
  onSubmit: {/*...*/},
  validationSchema: {/*...*/}
})

customElements.define('user-form', enhance(UserForm))

Shadow DOM, . .



«-» « » — API, , . , , , . LitElement , — .


, Shadow DOM ( ). .


Shadow DOM , . Shadow DOM . CSS , DOM .


- , , , . , , .



webcomponents.org, Polymer, LitElement, lit-html, Vaadin Router, Vaadin Components, lite-redux, lite-form, awesome-lit-html, Polyfills, custom-elements-builtin polyfill


All Articles