如何写好 JavaScript(二) | 青训营笔记

112 阅读7分钟

这是我参与「第四届青训营 」笔记创作活动的第 5 天。(第五篇笔记)

本节笔记内容继续上一篇笔记的主题「如何写好 JavaScript」,从「组件封装」的角度出发。

组件封装

组件封装指的是 Web 页面上抽取出一个包含模版(HTML)、功能 (JavaScript)、样式(CSS) 的单元。好的 UI 组件具备正确性、拓展性和复用性。

🌰 例子 / 一个轮播图组件:

使用原生 JavaScript 写一个轮播图:

  • 多张图片循环播放,每张图片短暂停留;
  • 用户可以通过两侧的 ⬅️ ➡️ 按钮控制图片的 上一张/下一张;
  • 用户可以通过底部的原点控制跳转到原点顺序的那张图片;

codepen:slider (codepen.io)

HTML / 确定结构

​
<div id="my-slider" class="slider-list">
  <ul>
    <li class="slider-list__item">
      <img src="https://source.unsplash.com/random/500x500/?ocean">
    </li>
    <li class="slider-list__item">
      <img src="https://source.unsplash.com/random/500x500/?sky">
    </li>
    <li class="slider-list__item">
      <img src="https://source.unsplash.com/random/500x500/?fruit">
    </li>
    <li class="slider-list__item--selected">
      <img src="https://source.unsplash.com/random/500x500/?cat">
    </li>
  </ul>
  <button class="slider-btn" id="slider-btn__prev">⬅️</button>
  <button class="slider-btn" id="slider-btn__next">➡️</button>
  <div class="slider-list__control">
    <span class="slider-list__control-buttons--selected"></span>
    <span class="slider-list__control-buttons"></span>
    <span class="slider-list__control-buttons"></span>
    <span class="slider-list__control-buttons"></span>
  </div>
</div>

基本结构为一个列表结构,使用无序列表 ul 实现,使用 li 放图片;圆点使用 span 元素实现。

类和 id 的命名方法 BEM(Block Element Modifier):

  • Block 块:逻辑和功能独立的单元(类似组件);
  • Element 元素:Block 的组成部分(使用 __ 为连接前缀);
  • Modifier 修饰符:用于修饰块或元素,体现出外观、行为、状态等特征 (使用 -- 为连接前缀);

🌰 slider-list__itemslider-list 为 Block 块组件名;item 指的是 Element 组成部分;slider-list__item--selectedselected 表示状态;

CSS / 样式

body {
  margin: 0;
  padding: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  overflow: hidden;
}
​
#my-slider {
  position: relative;
  width: 500px;
  height: 500px;
  overflow: hidden;
  box-shadow: rgba(255, 255, 255, 0.1) 0px 1px 1px 0px inset,
    rgba(50, 50, 93, 0.25) 0px 50px 100px -20px,
    rgba(0, 0, 0, 0.3) 0px 30px 60px -30px;
}
​
.slider-list ul {
  list-style-type: none;
  position: relative;
  padding: 0;
  margin: 0;
}
​
.slider-list ul img {
  width: 500px;
}
​
.slider-list__item,
.slider-list__item--selected {
  position: absolute;
  transition: opacity 1s;
  opacity: 0;
  text-align: center;
}
​
.slider-list__item--selected {
  transition: opacity 1s;
  opacity: 1;
}
​
.slider-list__control {
  z-index: 99;
  position: absolute;
  right: 45%;
  bottom: 10px;
}
​
.slider-list__control-buttons,
.slider-list__control-buttons--selected {
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background-color: white;
  cursor: pointer;
}
​
.slider-list__control-buttons--selected {
  background-color: rgb(69, 72, 86);
}
​
.slider-btn {
  outline: 0;
  border: 0;
  position: absolute;
  top: 50%;
  z-index: 99;
  font-size: 1.5rem;
  background: transparent;
  cursor: pointer;
}
​
#slider-btn__prev {
  left: 10px;
}
​
#slider-btn__next {
  right: 10px;
}

JavaScript / 行为设计

JavaScript 行为设计的原则:保证原子操作(执行某一操作不被打断的操作);职责单一;满足灵活性;

为了要实现轮播图的行为需求,设计以下的 API:

  • getSelectedItem(): 获取当前的图片;(为了设置 CSS 样式)
  • getSelectedItemIndex():获取当前图片的位置(索引);
  • slideTo():切换到某张图片;
  • slideNext():切换到下一张图片;
  • slidePrevious():切换到上一张图片;

然后将组件的行为封装为一个类 Sider

class Slider {
  constructor(id) {
    this.container = document.getElementById(id);
    this.items = this.container.querySelectorAll(
      ".slider-list__item, .slider-list__item--selected"
    );
  }
​
  getSelectedItem() {
    const selected = this.container.querySelector(
      ".slider-list__item--selected"
    );
    return selected;
  }
​
  getSelectedItemIndex() {
    return Array.from(this.items).indexOf(this.getSelectedItem());
  }
​
  slideTo(index) {
    const selected = this.getSelectedItem();
    if (selected) {
      selected.className = "slider-list__item";
    }
    const item = this.items[index];
    if (item) {
      item.className = "slider-list__item--selected";
    }
  }
​
  slideNext() {
    const currentIndex = this.getSelectedItemIndex();
    const nextIndex = (currentIndex + 1) % this.items.length;
    this.slideTo(nextIndex);
  }
​
  slidePrevious() {
    const currentIndex = this.getSelectedItemIndex();
    const previousIndex =
      (this.items.length + currentIndex - 1) % this.items.length;
    this.slideTo(previousIndex);
  }
​
  start() {
    this.stop();
    this._timer = setInterval(() => {
      this.slideNext();
    }, 2000);
  }
​
  stop() {
    clearInterval(this._timer);
  }
}

使用:

const slider = new Slider("my-slider");
slider.start();

用户控制 / 进一步设计

实现 API 设计后,还需要实现用户控制的功能。按照需求:

  • 用户点击两侧的箭头时,图片分别会切换到上一张或者下一张;并且底部的原点会发生变化;
  • 用户鼠标移进小圆点时,停止自动轮播,并且转到选中原点的顺序的图片;
  • 用户鼠标移出小圆点时,恢复自动轮播;

根据需求修改上面的 JavaScript 代码:

首先,在 Slider 类中添加 start()stop() 两个 API 控制开启自动轮播和停止自动轮播:

start() {
  this.stop();
  this._timer = setInterval(() => {
    this.slideNext();
  }, 2000);
}
​
stop() {
  clearInterval(this._timer);
}

然后,将 Slider 的构造器添加以下行为:

const controller = this.container.querySelector(".slider-list__control");
const controlBtns = this.container.querySelectorAll(
  ".slider-list__control-buttons, .slider-list__control-buttons--selected"
);
​
controller.addEventListener("mouseover", (event) => {
  const index = Array.from(controlBtns).indexOf(event.target);
  if (index >= 0) {
    this.slideTo(index);
    this.stop();
  }
});
​
controller.addEventListener("mouseout", (event) => {
  this.start();
});
​
this.container.addEventListener("slide", (event) => {
  const index = event.detail.index;
  const selected = controller.querySelector(
    ".slider-list__control-buttons--selected"
  );
  if (selected) {
    selected.className = "slider-list__control-buttons";
    controlBtns[index].className = "slider-list__control-buttons--selected";
  }
});
​
const prevBtn = document.querySelector("#slider-btn__prev");
const nextBtn = document.querySelector("#slider-btn__next");
​
prevBtn.addEventListener("click", () => {
  this.stop();
  slider.slidePrevious();
  this.start();
  event.preventDefault();
});
​
nextBtn.addEventListener("click", () => {
  this.stop();
  slider.slideNext();
  this.start();
  event.preventDefault();
});

然后,修改 slideTo 方法,添加自定义事件的触发:

slideTo(index) {
  const selected = this.getSelectedItem();
  if (selected) {
    selected.className = "slider-list__item";
  }
  const item = this.items[index];
  if (item) {
    item.className = "slider-list__item--selected";
  }
​
  const detail = { index: index };
  const event = new CustomEvent("slide", { bubbles: true, detail });
  this.container.dispatchEvent(event);
}

这个自定义事件 slide 的作用是让底部的圆点可以根据切换到目前的图片,更新圆点的状态(样式),通过传递切换到的图片的索引,然后设置其他的圆点和选中的圆点的样式。

至此,在这个轮播图 例子的组件已经完成了全部的基本功能。并且包含封装性、正确性,但是还缺少了拓展性和复用性。

组件封装的基本方法

  • 结构设计 HTML

  • 样式设计 CSS

  • 行为设计 JavaScript

  • 组件设计

    • API 功能
    • Event 控制流 / 事件

一个好的组件应该具备封装性、正确性、拓展性和复用性的特点。

组件的重构解耦

在上面的 轮播图 例子中,左右切换按钮和底部的原点可能在别的项目中不需要。此时应该可以在 HTML 中直接替换掉这部分的代码,而不是修改 JavaScript 的核心逻辑代码。所以需要将这些控件部分分离出来(解耦)。

将控制元素抽取出来为插件

左右切换按钮和底部小圆点分别可以为三个插件:

  • pluginController() 圆点控制
  • pluginPrevious() 控制切换上一张
  • pluginNext() 控制切换下一张
const pluginController = (slider) => {
  const controller = slider.container.querySelector(".slider-list__control");
  const controlBtns = slider.container.querySelectorAll(
    ".slider-list__control-buttons, .slider-list__control-buttons--selected"
  );
​
  controller.addEventListener("mouseover", (event) => {
    const index = Array.from(controlBtns).indexOf(event.target);
    if (index >= 0) {
      slider.slideTo(index);
      slider.stop();
    }
  });
​
  controller.addEventListener("mouseout", (event) => {
    slider.start();
  });
​
  slider.container.addEventListener("slide", (event) => {
    const index = event.detail.index;
    const selected = controller.querySelector(
      ".slider-list__control-buttons--selected"
    );
    if (selected) {
      selected.className = "slider-list__control-buttons";
      controlBtns[index].className = "slider-list__control-buttons--selected";
    }
  });
};
​
const pluginPrevious = (silder) => {
  const prevBtn = slider.container.querySelector("#slider-btn__prev");
​
  prevBtn.addEventListener("click", (event) => {
    slider.stop();
    slider.slidePrevious();
    slider.start();
    event.preventDefault();
  });
};
​
const pluginNext = (slider) => {
  const nextBtn = slider.container.querySelector("#slider-btn__next");
​
  nextBtn.addEventListener("click", (event) => {
    slider.stop();
    slider.slideNext();
    slider.start();
    event.preventDefault();
  });
};

插件与组件之间通过依赖注入方式建立联系

依赖注入:将有依赖关系的类放入容器中,解析出这些类的实例,就是依赖注入,目的是实现类的解耦。

Slider 类添加注册函数的 API registerPlugins,可以传入插件并且使用:

registerPlugins(...plugins) {
  plugins.forEach((plugin) => plugin(this));
}

最后调用的方式为:

const slider = new Slider("my-slider");
slider.registerPlugins(pluginController, pluginNext, pluginPrevious);
slider.start();

现在可以自由控制组件的控件的使用,如果不想要圆点或者切换效果的行为,可以直接删除掉引入的插件(依赖)。但是 HTML 结构依旧需要手动删除。所以为了更好的拓展型,进一步优化:HTML 模块化。

HTML 模块化

首先,在 HTML 中只留下根标签:

<div id="my-slider" class="slider-list"></div>

利用 render 函数渲染其余的 HTML 结构(通过循环生成相同的结构部分):

class Slider {
  constructor(
    id,
    options = {
      images: [],
      cycle: 3000
    }
  ) {
    this.container = document.getElementById(id);
    this.options = options;
    this.containner.innerHTML = this.render();
    this.items = this.container.querySelectorAll(
      ".slider-list__item, .slider-list__item--selected"
    );
    this.cycle = options.cycle || 3000;
    this.slideTo(0);
  }
  // ...
  render() {
    const images = this.options.images;
    const content = images.map((image) =>
      `
      <li class="slider-list__item">
        <img src="${image}"/>
      </li>
    `.trim()
    );
    return `<ul>${content.join('')}</ul>`
  }
  // ... 
}

修改三个控件插件为渲染和行为方法:

const pluginController = {
  render(images) {
    return `<div class="slider-list__control">
      ${images
        .map(
          (image, i) =>
            `<span class="slider-list__control-buttons${
              i === 0 ? "--selected" : ""
            }"></span>`
        )
        .join("")}
    </div>`.trim();
  },
​
  action(slider) {
    const controller = slider.container.querySelector(".slider-list__control");
    if (controller) {
      const controlBtns = slider.container.querySelectorAll(
        ".slider-list__control-buttons, .slider-list__control-buttons--selected"
      );
​
      controller.addEventListener("mouseover", (event) => {
        const index = Array.from(controlBtns).indexOf(event.target);
        if (index >= 0) {
          slider.slideTo(index);
          slider.stop();
        }
      });
​
      controller.addEventListener("mouseout", (event) => {
        slider.start();
      });
​
      slider.container.addEventListener("slide", (event) => {
        const index = event.detail.index;
        const selected = controller.querySelector(
          ".slider-list__control-buttons--selected"
        );
        if (selected) {
          selected.className = "slider-list__control-buttons";
          controlBtns[index].className =
            "slider-list__control-buttons--selected";
        }
      });
    }
  }
};
​
const pluginPrevious = {
  render() {
    return `<button class="slider-btn" id="slider-btn__prev">⬅️</button>`;
  },
  action(slider) {
    const prevBtn = slider.container.querySelector("#slider-btn__prev");
​
    prevBtn.addEventListener("click", (event) => {
      slider.stop();
      slider.slidePrevious();
      slider.start();
      event.preventDefault();
    });
  }
};
​
const pluginNext = {
  render() {
    return `<button class="slider-btn" id="slider-btn__next">➡️</button>`;
  },
  action(slider) {
    const nextBtn = slider.container.querySelector("#slider-btn__next");
​
    nextBtn.addEventListener("click", (event) => {
      slider.stop();
      slider.slideNext();
      slider.start();
      event.preventDefault();
    });
  }
};

注册插件的方法修改为:

registerPlugins(...plugins) {
    plugins.forEach((plugin) => {
      const pluginContainer = document.createElement("div");
      pluginContainer.className = ".slider-list__plugin";
      pluginContainer.innerHTML = plugin.render(this.options.images);
      this.container.appendChild(pluginContainer);
      plugin.action(this);
    });
  }

最终调试过程修改为:

const slider = new Slider("my-slider", {
  images: [
    "https://source.unsplash.com/random/500x500/?ocean",
    "https://source.unsplash.com/random/500x500/?sky",
    "https://source.unsplash.com/random/500x500/?dog",
    "https://source.unsplash.com/random/500x500/?cat"
  ]
});
slider.registerPlugins(pluginController, pluginNext, pluginPrevious);
slider.start();

通过以上的步骤,实现了 HTML 的模块化。此时,如果不想要某个插件,可以直接不引入该插件的依赖,并且不需要修改原来 HTML 结构,就可以不再出现插件(控件)相关的 UI 和功能;同时需要新的插件只需要再重写和注册(引入依赖)即可。虽然现在组件的拓展型大大提高 ,但是要做到复用性还需要进一步优化。

组件框架的抽象化

将组件的通用模型抽象出来。

在上面的例子中,HTML 模版化时,registerPluginsrender 方法是通用的,可以将它们抽取出阿里放在 Component 类中(通用解决组件相关方法的类),然后让轮播组件的 Slider 类通过继承 Component 类实现 render 方法。

首先,抽象出来 Component 类:

class Component {
  constructor(
    id,
    options = {
      name: "",
      data: [],
    }
  ) {
    this.container = document.getElementById(id);
    this.options = options;
    this.container.innerHTML = this.render(options.data);
  }
​
  registerPlugins(...plugins) {
    plugins.forEach((plugin) => {
      const pluginContainer = document.createElement("div");
      pluginContainer.className = `.${this.options.name}__plugin`;
      pluginContainer.innerHTML = plugin.render(this.options.data);
      this.container.appendChild(pluginContainer);
      plugin.action(this);
    });
  }
​
  render(data) {
    // abstract
    return "";
  }
}

这个类体系完整,相当于是一个小型的组件框架。

然后在子类(Slider 类)中集成实现这个父类(Component):

class Slider extends Component {
  constructor(
    id,
    options = {
      name: "slider-list",
      data: [],
      cycle: 3000,
    }
  ) {
    super(id, options);
​
    this.items = this.container.querySelectorAll(
      ".slider-list__item, .slider-list__item--selected"
    );
    this.cycle = options.cycle || 3000;
    this.slideTo(0);
  }
​
  render(data) {
    const content = data.map((image) =>
      `<li class="slider-list__item">
        <img src="${image}"/>
      </li>`.trim()
    );
    return `<ul>${content.join("")}</ul>`;
  }
  // ...
}

使用 super() 调用了父类的构造函数。

最后调用时:

const slider = new Slider("my-slider", {
  name: "slider-list",
  data: [
    "https://source.unsplash.com/random/500x500/?ocean",
    "https://source.unsplash.com/random/500x500/?sky",
    "https://source.unsplash.com/random/500x500/?dog",
    "https://source.unsplash.com/random/500x500/?cat",
  ],
  cycle: 2000,
});
slider.registerPlugins(pluginController, pluginNext, pluginPrevious);
slider.start();

组件封装总结

在上面的例子中,经过几个版本的迭代重构解耦(插件化、模版化、抽象化),最后得到了一个同时具备「封装性、正确性、拓展性、复用性」的 UI 组件,但是这个组件还是还能进一步改进,CSS 模版化、父子组件的状态同步和消息通信。