这是我参与「第四届青训营 」笔记创作活动的第 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__item:slider-list为 Block 块组件名;item指的是 Element 组成部分;slider-list__item--selected:selected表示状态;
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 模版化时,registerPlugins 、 render 方法是通用的,可以将它们抽取出阿里放在 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 模版化、父子组件的状态同步和消息通信。