纯原生组件化-模块化的探索

纯原生的组件化、模块化的一次小小的尝试,用到了以下几个新特性:
shadown-DOM 对HTML标签结构的一个封装,真正意义上的组件,能保证 shadow-DOM 中的DOM元素不会被外界影响,内部也不会影响到外部的行为,变成了一个独立的模块。
custom-elements 能够在浏览器中注册自定义的标签来使用,相似这样的效果<my-tag></my-tag>,标签内容基于两种形式:1. 普通子元素 2. shadow-DOM
custom-events 使用各类自定义事件辅助完成组件之间的通信
ES-module 为浏览器原生支持的模块化的一种方案,直接在浏览器里使用importexport这类语法,以 module 的方式来引入 js 文件。
几个算是比较新的事物,汇集在一块儿确实能够作点儿好玩的东西出来。javascript

shadow-DOM

想象有这样的一个场景,相似资料卡的东东,须要在页面中展现头像和用户的名称。
头像在左,宽高100px,圆形;
姓名在右,字号16px,垂直居中。css

这算是一段很简单的CSS了,实现起来大概是这样的:html

<style>
.info { display: flex; }

.info-avatar { width: 100px; height: 100px; border-radius: 50%; }

.info-name { display: flex; align-items: center; font-size: 16px; }
</style>
<div class="info">
  <img class="info-avatar" src="https://avatars1.githubusercontent.com/u/9568094?v=4" />
  <p class="info-name">Jarvis</p>
</div>

 

此时,咱们完成了需求,一切都没有什么不对的,可是一个很现实的问题。
不会有这么简单的页面存在的,就算简洁如 Google 首页,也用到了400左右的DOM元素。
很难保证其余资源文件中的CSSJS会不会对上边的DOM产生影响。
就好比若是有一个main.css文件中写了一行:p { color: red;},那么这条CSS就会对咱们上边所写的.info-name元素产生影响,致使文本颜色变为红色。前端

这种问题常常会出如今一些须要用到第三方插件的页面中,极可能对方提供的CSS会影响到你的DOM元素,也颇有可能你的CSS会对插件中的DOM形成影响。java

解决这个问题有一种简单的办法,那就是All with !important,使用shadow-DOMwebpack

目前浏览器中就有一些shadow-DOM的例子:git

  • <video>
  • <audio>
  • 甚至<input>

这些元素在 Chrome 上的构建都是采用了shadow-DOM的方式,可是默认状况下在开发者工具中是看不到这些元素的。
github

开启shadow-DOM的流程: Chrome DevTools -> Settings -> 默认 Preferences 面板中找到 Elements -> 点击勾选 Show user agent shadow DOM 便可web

这时候就能够经过开发者工具查看到shadow-DOM的实际结构了。
浏览器

shadow-DOM的一个特色,shadow 里边全部的DOM元素不会被外界的代码所影响,这也就是为何videoaudio的 UI 难以自定义的缘由了-.-。

基本语法

shadow-DOM的建立必需要使用JavaScript才能完成,咱们须要在文档中有一个用于挂在shadow-DOM的真实元素,也被称为host
除此以外的建立过程,就能够像普通DOM树那样的增删改子元素了。

let $tag = document.querySelector('XXX') // 用于挂载的真实元素

let shadow = $tag.attachShadow({ mode: 'open' }) // 挂载shadow-DOM元素,并获取其根元素

 

attachShadow中的mode参数有两个有效的取值,openclosed,用来指定一个 shadow-DOM 结构的封装模式。

当值为open时,则咱们能够经过挂载时使用的真实元素获取到shadow-DOM

$tag.shadowRoot; // shadow-DOM的root元素

 

当值为closed时,则表示外层没法获取shadow-DOM

$tag.shadowRoot; // null

 

后续的操做就与普通的DOM操做一致了,各类appendremoveinnerHTML均可以了。

let $shadow = $tag.attachShadow({ mode: 'open' })

let $img = document.createElement('img')
$shadow.appendChild($img)  // 添加一个img标签到shadow-DOM中

$shadow.removeChild($img) // 将img标签从shadow-DOM中移除

$img.addEventListener('click', _ => console.log('click on img'))

$shadow.innerHTML = `
  <div class="wrap">
    <p>Some Text</p>
  </div>
`

 

须要注意的一点是,shadow-DOM自己并非一个实际的标签,不具有定义CSS的能力。
可是绑定事件是能够的

$shadow.appendChild('<p></p>') // 伪装add了一个标签
$shadow.appendChild('<p></p>') // 伪装add了一个标签

// 最后获得的结构就是
// <外层容器>
//   <p></p>
//   <p></p>
// </外层容器>

// 没有class相关的属性
$shadow.classList // undefined
$shadow.className // undefined
$shadow.style     // undefined
// 绑定事件是没问题的
$shadow.addEventListener('click', console.log)

 

shadow-DOM也会有CSS的属性继承,而不是彻底的忽略全部外层CSS

<style>
  body {
    font-size: 16px;  /* 属性会被.text元素继承 */
  }
  .host {
    color: red;       /* 一样会被.text元素继承 */
  }

  .text {
    color: green;     /* 直接设置shadow内的元素是无效的 */
  }

  p {
    font-size: 24px;  /* 针对p标签的设置也不会被.text应用 */
  }

  /* 对外层设置flex,内部元素也会直接应用(但为了保证对外层元素的非侵入性,建议内部建立一个容器DOM) */
  .host {
    display: flex;
  }
  .text {
    flex: 1;
  }
</style>
<div class="host">
  #shadow
    <p class="text">Text</p>
    <p class="text">Text</p>
  #shadow
</div>

 

因此说,对于shadow-DOM,CSS只是屏蔽了直接命中了内部元素的那一部分规则。
好比说写了一个* { color: red; },这个规则确定会生效的,由于*表明了所有,实际上shadow-DOM是从外层host元素继承过来的color: red,而不直接是命中本身的这条规则。

简单的小例子

咱们使用shadow-DOM来修改上边的资料卡。

在线demo
源码地址

<div id="info"></div>
<script>
  let $info = document.querySelector('#info') // host

  let $shadow = $info.attachShadow({mode: 'open'})

  let $style = document.createElement('style')
  let $wrap = document.createElement('div')
  let $avatar = document.createElement('img')
  let $name = document.createElement('p')

  $style.textContent = `
    .info { display: flex; }
    .info-avatar { width: 100px; height: 100px; border-radius: 50%; }
    .info-name { display: flex; align-items: center; font-size: 16px; }
  `

  $wrap.className = 'info'
  $avatar.className = 'info-avatar'
  $name.className = 'info-name'

  $avatar.src = 'https://avatars1.githubusercontent.com/u/9568094?v=4'
  $name.innerHTML = 'Jarvis'

  $wrap.appendChild($avatar)
  $wrap.appendChild($name)

  $shadow.appendChild($style)
  $shadow.appendChild($wrap)
</script>

 

P.S. 在 shadow-DOM 内部的 css,不会对外界所产生影响,因此使用 shadow-DOM 就能够肆意的对 class 进行命名而不用担忧冲突了。

若是如今在一个页面中要展现多个用户的头像+姓名,咱们能够将上边的代码进行封装,将 classNameappendChild之类的操做放到一个函数中去,相似这样的结构:

在线demo
源码地址

function initShadow($host, { isOpen, avatar, name }) {
  let $shadow = $host.attachShadow({ mode: isOpen ? 'open' : 'closed' });

  // ...省略各类操做
  $avatar.src = avatar
  $name.innerHTML = name
}

initShadow(document.querySelector('#info1'), {
  avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4',
  name: 'Jarvis'
});
initShadow(document.querySelector('#info2'), { 
  isOpen: true,
  avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4',
  name: 'Jarvis' 
})

 

这样就实现了一个简单的组件,能够在须要用到的地方,直接传入一个挂载的DOM便可。

custom-elements

就像上边的shadow-DOM,已经在文档树中看不到组件的细节了,任何代码也都不会影响到它的结构(open模式下的获取root操做除外)。
可是,这样在文档中是存在一个用来挂在shadow-DOM的根元素,这个根元素依然是一个普通的HTML标签。
若是是一个大型页面中,存在了N多相似的组件,搜索一下,全是<div></div>,这个体验实际上是很痛苦的,基本是毫无语义化。
并且咱们想要使用这个组件时,还必须额外的调用JavaScript来获取DOM元素生成对应的shadow-DOM
因此,咱们能够尝试用custom-elements来注册本身独有的标签。
简单的经过<my-tag>的方式来调用自定义组件。

custom-elements支持同时支持普通标签的封装以及shadow-DOM的封装,但二者不能共存。

基本语法

首先咱们须要有一个继承了HTMLElement的类。
而后须要将其注册到当前环境中。

class Info extends HTMLElement {}

customElements.define(
  'cus-info', // 标签名
  Info        // 标签对应的构造函数
)

 

在调用define时还有一个可选的第三个参数,用来设置自定义标签继承自某个原生标签。
二者在后续的标签使用上稍微有些区别:

<!-- 若是设置了 { extends: 'p' } -->
<p is="cus-info" />
<script>
  document.createElement('p', { is: 'cus-info' })
</script>
<!-- 没有设置 extends 的状况 -->
<info />
<script>
  document.createElement('cus-info') // 必需要包含一个`-`
</script>

 

P.S. 自定义的标签的注册名至少要包含一个-
结合场景来选择是否使用extends,我的不建议使用,由于看起来会舒服一些

普通标签的方式

若是是针对普通的一组标签进行封装,就是解决了一些相同功能的组件须要在页面中粘来粘去的问题。

在线demo
源码地址

<cus-info>
  <p>native text</p>
  <!-- 默认是能够直接嵌套的,除非在自定义组件中移除 -->
</cus-info>
<script>
  class CusInfo extends HTMLElement {
    constructor() {
      super()

      let $text = document.createElement('p')
      $text.innerHTML = 'Hello custom-elements.'

      this.appendChild($text) // this表明当前自定义元素的实例
    }
  }

  customElements.define('cus-info', CusInfo)
</script>

 

实现相似这样的效果:

shadow-DOM的使用方式

P.S. 当一个元素激活了shadow-DOM之后,里边的普通子元素都会变得不可见,可是使用DOM API依然能够获取到

在线demo
源码地址

<cus-info>
  <p>native text</p>
  <!-- 默认是能够直接嵌套的,除非在自定义组件中移除 -->
</cus-info>
<script>
  class CusInfo extends HTMLElement {
    constructor() {
      super()

      let $shadow = this.attachShadow({ mode: 'open' })
      let $text = document.createElement('p')
      $text.innerHTML = 'Hello custom-elements.'

      $shadow.appendChild($text)
    }
  }

  customElements.define('cus-info', CusInfo)
  console.log(document.querySelector('cus-info').children[0].innerHTML) // native text
</script>

 

生命周期函数

自定义标签并不仅是一个让你多了一个标签能够用。
注册的自定义标签是有一些生命周期函数能够设置的,目前有效的事件为:

  • connectedCallback 标签被添加到文档流中触发
  • disconnectedCallback 标签被从文档流中移除时触发
  • adoptedCallback 标签被移动时触发,现有的API貌似没有一个能够触发这个事件的,由于像appendChild或者insertBefore这一类的,对于已经存在的DOM元素都是先移除后新增的,因此不存在有直接移动的行为
  • attributeChangedCallback 增删改元素属性时会触发 须要提早设置observedAttributes,才能监听对应的属性变化

一个触发各类事件的简单示例:

在线demo
源码地址

<div id="wrap">
  <div id="content"></div>
</div>
<script>
  class CusTag extends HTMLElement {
    static get observedAttributes() { return ['id'] } // 设置监听哪些属性变化
    connectedCallback () { console.log('DOM被添加到文档中') }
    disconnectedCallback () { console.log('DOM被从文档中移除') }
    adoptedCallback () { console.log('DOM被移动') }
    attributeChangedCallback () { console.log('DOM属性有修改') }
  }

  customElements.define('cus-tag', CusTag)

  let $wrap = document.querySelector('#wrap')
  let $content = document.querySelector('#content')
  let $tag = document.createElement('cus-tag')

  $wrap.appendChild($tag)
  $content.appendChild($tag)
  $tag.setAttribute('id', 'tag-id')
  $tag.setAttribute('id', 'tag-id2')
  $tag.removeAttribute('id')
  $content.removeChild($tag)
</script>

 

P.S. 若是须要处理DOM结构以及绑定事件,推荐在connectedCallback回调中执行
想要attributeChangedCallback生效,必须设置observedAttributes来返回该标签须要监听哪些属性的改变

使用自定义标签封装资料卡组件

接下来就是使用custome-elements结合着shadow-DOM来完成资料卡的一个简单封装。
由于shadow-DOM版本的组件相对更独立一些,因此这里采用的是shadow-DOM的方式进行封装。
大体代码以下:

在线demo
源码地址

<info-card name="Jarvis" avatar="https://avatars1.githubusercontent.com/u/9568094?v=4" />
<!-- P.S. 这里会触发一个Chrome67版本的一个隐藏bug -->
<script>
  class InfoCard extends HTMLElement {
    connectedCallback () {
      // 稳妥的方式是在肯定标签已经被添加到DOM中在进行渲染
      let avatar = this.getAttribute('avatar')
      let name = this.getAttribute('name')
      initShadow(this, { avatar, name })
    }
  }

  customElements.define('info-card', InfoCard)
</script>

 

针对上边的initShadow调用也只是更换了avatarname字段的来源罢了。
如今,咱们须要在页面中使用封装好的资料卡,仅仅须要注册一个自定义标签,而后在HTML中写对应的标签代码便可

再开一下脑洞

由于是采用了注册html标签的方式,其实这个是对采用Server端模版渲染特别友好的一件事儿。
若是有使用服务端渲染的页面,可能会动态的拼接一些DOM元素到请求的返回值中。
为了应用一些样式,可能须要在模版中添加各类className,也颇有可能手一抖之类的就会致使标签没有闭合、结构错乱,或者某些属性拼写出错,各类233的问题。
好比插入一些表单元素,以前多是这样的代码:

router.get('/', ctx => {
  ctx.body = `
    <body>
      <form>
        <div class="form-group">
          <label for="account">Account</label>
          <input id="account" placholder="put account" />
        </div>
        <div class="form-group">
          <label for="password">Account</label>
          <input id="password" placholder="put password" type="password" />
        </div>
        <button>Login</button>
      </form>
    </body>
  `
})

 

在使用了custom-elements之后,Server端的记忆成本也会下降不少。
Server端只须要代表这里有一个表单元素就够了,具体渲染成什么样,仍是交由前端来决定。

router.get('/', ctx => {
  ctx.body = `
    <body>
      <form>
        <form-field id="account" label="Account" placholder="put account" />
        <form-field id="password" label="Password" placholder="put password" type="password" />
        <form-login />
      </form>
    </body>
  `
})

 

custom-events

若是在页面中使用不少的自定义组件,必然会遇到组件之间的通信问题的。
好比我一个按钮点击了之后如何触发其余组件的行为。
由于是纯原生的版本,因此自然的支持addEventListener,咱们能够直接使用custom-events来完成组件之间的通信。

基本语法

使用自定义事件与原生DOM事件惟一的区别就在于须要本身构建Event实例并触发事件:

document.body.addEventListener('ping', _ => console.log('pong')) // 设置事件监听

document.body.dispatchEvent(new Event('ping')) // 触发事件

 

自定义组件中的使用

如今页面中有两个组件,一个容器,容器中包含一个文本框和数个按钮,点击按钮之后会将按钮对应的文字输出到文本框中:

在线demo
源码地址

<cus-list>
  <input id="output" />
  <cus-btn data-text="Button 1"></cus-btn>
  <cus-btn data-text="Button 2"></cus-btn>
  <cus-btn data-text="Button 3"></cus-btn>
</cus-list>
<script>
  class CusList extends HTMLElement {
    connectedCallback() {
      let $output = this.querySelector('#output')
      Array.from(this.children).forEach(item => {
        if (item.tagName.toLowerCase() === 'cus-btn') {
          item.addEventListener('check', event => { // 注册自定义事件的监听
            $output.value = event.target.innerText
          })
        }
      })
    }
  }
  class CusBtn extends HTMLElement {
    connectedCallback() {
      let { text } = this.dataset

      let $text = document.createElement('p')
      $text.innerHTML = text

      $text.addEventListener('click', _ => {
        this.dispatchEvent(new Event('check')) // 触发自定义事件
      })

      this.appendChild($text)
    }
  }

  customElements.define('cus-list', CusList)
  customElements.define('cus-btn', CusBtn)
</script>

 

上边是在List中循环了本身的子节点,而后依次绑定事件,这种处理是低效的,并且是不灵活的。
若是有新增的子元素,则没法触发对应的事件。
因此,咱们能够开启事件的冒泡来简化上边的代码:

在线demo
源码地址

class CusList extends HTMLElement {
  connectedCallback() {
    let $output = this.querySelector('#output')

    this.addEventListener('check', event => { // 注册自定义事件的监听
      $output.value = event.target.innerText // 效果同样,由于event.target就是触发dispatchEvent的那个DOM对象
    })
  }
}
class CusBtn extends HTMLElement {
  connectedCallback() {
    let { text } = this.dataset

    let $text = document.createElement('p')
    $text.innerHTML = text

    $text.addEventListener('click', _ => {
      this.dispatchEvent(new Event('check'), {
        bubbles: true // 启用事件冒泡
      }) // 触发自定义事件
    })

    this.appendChild($text)
  }
}

 

ES-module

ES-module是原生模块化的一种实现,使用ES-module可让咱们上边组件的调用变得更方便。
这里有以前的一篇讲解ES-module的文章:传送阵
因此,再也不赘述一些module相关的基础,直接将封装好的组件代码挪到一个js文件中,而后在页面中引用对应的js文件完成调用。

在线demo
源码地址

module.js

export default class InfoCard extends HTMLElement { }

customElements.define('info-card', InfoCard)

 

index.html

<info-card name="Jarvis" avatar="https://avatars1.githubusercontent.com/u/9568094?v=4"></info-card>
<script type="module" src="./cus-elements-info-card.js"></script>

 

第一眼看上去,这样作好像与普通的js脚本引入并无什么区别。
确实单纯的写这一个组件的话,是没有什么区别的。

可是一个现实中的页面,不会只有这么一个组件的,假设有这样的一个页面,其中包含了三个组件:

<cus-tab>
  <cus-list>
    <cus-card />
    <cus-card />
  </cus-list>
  <cus-list>
    <cus-card />
    <cus-card />
  </cus-list>
</cus-tab>

 

咱们在使用list时要保证card已经加载完成,在使用tab时要保证list已经加载完成。
最简单的方法就是等到全部的资源所有加载完成后再执行代码,主流的webpack打包就是这么作的。
可是,这样作带来的后果就是,明明listcard加载完毕后就能够处理本身的逻辑,注册自定义标签了,却仍是要等外层的tab加载完毕后再执行代码。
这个在使用webpack打包的ReactVue这类框架上边就是很明显的问题,若是打包完的js文件过大,几百k,甚至数兆。
须要等到这个文件所有下载完毕后才会开始运行代码,构建页面。

咱们彻底能够利用下载其余组件时的空白期来执行当前组件的一些逻辑,而使用webpack这类打包工具却不能作到,这很显然是一个时间上的浪费,而ES-module已经帮忙处理了这件事儿,module代码的执行是创建在全部的依赖所有加载完毕的基础上的。

cardlist加载完毕后,list就会开始执行代码。而此时的tab可能还在加载过程当中,等到tab加载完毕开始执行时,list已经注册到了document上,就等着被调用了,从某种程度上打散了代码执行过于集中的问题。
可能以前页面加载有200ms在下载文件,有50ms在构建组件,50ms渲染页面(数值纯属扯淡,仅用于举例)
有些组件比较轻量级,可能用了20ms就已经下载完了文件,若是它没有依赖其余的module,这时就会开始执行自身组件的一些代码,生成构造函数、注册自定义组件到文档中,而这些步骤执行的过程当中可能浏览器还在下载其余的module,因此这就是两条并行的线了,让一部分代码执行的时间和网络请求消耗的时间所重叠

举一个现实中的例子:
你开了一家饭店,雇佣了三个厨师,一个作番茄炒蛋、一个作皮蛋豆腐、还有一个作拍黄瓜,由于场地有限,因此三个厨师共用一套炊具。(单线程)
今天第一天开业,这时候来了客人点了这三样菜,可是菜还在路上。
webpack:「西红柿、鸡蛋、皮蛋、豆腐、黄瓜」全放到一块给你送过来,送到了之后,三个厨师轮着作,而后给客人端过去。
ES-module:分拨送,什么菜先送过来就先作哪一个,哪一个先作完给客人端哪一个。

一个简单的组件嵌套示例

在线demo
源码地址

cus-elements-info-list.js

import InfoCard from './cus-elements-info-card.js'

export default class InfoList extends HTMLElement {
  connectedCallback() {
    // load data
    let data = [
      {
        avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4',
        name: 'Jarvis'
      },
      {
        avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4',
        name: 'Jarvis'
      },
      {
        avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4',
        name: 'Jarvis'
      }
    ]
    // laod data end

    initShadow(this, { data })
  }
}

function initShadow($host, { data, isOpen }) {
  let $shadow = $host.attachShadow({ mode: isOpen ? 'open' : 'closed' })

  let $style = document.createElement('style')
  let $wrap = document.createElement('div')

  $style.textContent = `
    .list { display: flex; flex-direction: column; }
  `

  $wrap.className = 'list'

  // loop create
  data.forEach(item => {
    let $item = new InfoCard()
    $item.setAttribute('avatar', item.avatar)
    $item.setAttribute('name', item.name)

    $wrap.appendChild($item)
  })

  $shadow.appendChild($style)
  $shadow.appendChild($wrap)
}

customElements.define('info-list', InfoList)

 

<info-list></info-list>
<script type="module" src="./cus-elements-info-list.js"></script>

 

new Component与document.createElement效果同样,用于在不知道组件的注册名的状况下使用

总结

一些小提示

  1. shadow-DOM没法与普通的子元素共存,设置attachShadow之后会致使普通子元素在页面不可见,可是DOM依然保留
  2. custom-elements的注册名必需要包含一个-
  3. custom-elementsconstructor函数触发时不能保证DOM已经正确渲染完毕,对DOM进行的操做应该放到connectedCallback
  4. custom-elements组件的属性变化监听须要提早配置observedAttributes,没有通配符之类的操做
  5. ES-module相关的操做只能在type="module"中进行
  6. ES-module的引用是共享的,即便十个文件都import了同一个JS文件,他们拿到的都是同一个对象,不用担忧浪费网络资源

一个简单的TODO-LIST的实现:

在线demo
源码地址

浏览器原生支持的功能愈来愈丰富,ES-modulecustom-elementsshadow-DOM以及各类新鲜的玩意儿;
web原生的组件化、模块化,期待着普及的那一天,就像如今能够放肆的使用qsa 、fetch,而不用考虑是否须要引入jQuery来帮助作兼容同样(大部分状况下)。

参考资料

  1. shadow-DOM | MDN
  2. custom-elements | MDN
  3. custom-events | MDN
  4. ES-module | MDN

文中全部示例的仓库地址

仓库地址

相关文章
相关标签/搜索