封裝性、正確性、擴展性、重用性
輪播圖#
確定 UI 組件的 HTML 結構#
<div class="slider">
<ul>
<li class="slider__item--selected">
<img src="https://p5.ssl.qhimg.com/t0119c74624763dd070.png"/>
</li>
<li class="slider__item">
<img src="https://p4.ssl.qhimg.com/t01adbe3351db853eb3.jpg"/>
</li>
<li class="slider__item">
<img src="https://p2.ssl.qhimg.com/t01645cd5ba0c3b60cb.jpg"/>
</li>
<li class="slider__item">
<img src="https://p4.ssl.qhimg.com/t01331ac159b58f5478.jpg"/>
</li>
</ul>
<a class="slider__next"></a>
<a class="slider__previous"></a>
<div class="slider__control">
<span class="slider__control-buttons--selected"></span>
<span class="slider__control-buttons"></span>
<span class="slider__control-buttons"></span>
<span class="slider__control-buttons"></span>
</div>
</div>
設置元素的樣式#
.slider {
position: relative;
width: 790px;
height: 340px;
}
.slider ul {
list-style-type:none;
position: relative;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
.slider__item,
.slider__item--selected {
position: absolute;
transition: opacity 1s;
opacity: 0;
text-align: center;
}
.slider__item--selected {
transition: opacity 1s;
opacity: 1;
}
.slider__next,
.slider__previous{
display: inline-block;
position: absolute;
top: 50%; /*定位在錄播圖組件的縱向中間的位置*/
margin-top: -25px;
width: 30px;
height:50px;
text-align: center;
font-size: 24px;
line-height: 50px;
overflow: hidden;
border: none;
color: white;
background: rgba(0,0,0,0.2); /*設置為半透明*/
cursor: pointer; /*設置鼠標移動到這個元素時顯示為手指狀*/
opacity: 0; /*初始狀態為透明*/
transition: opacity .5s; /*設置透明度變化的動畫,時間為.5秒*/
}
.slider__previous {
left: 0; /*定位在slider元素的最左邊*/
}
.slider__next {
right: 0; /*定位在slider元素的最右邊*/
}
.slider:hover .slider__previous {
opacity: 1;
}
.slider:hover .slider__next {
opacity: 1;
}
.slider__previous:after {
content: '<';
}
.slider__next:after {
content: '>';
}
.slider__control{
position: relative;
display: table; /* table 布局*/
background-color: rgba(255, 255, 255, 0.5);
padding: 5px;
border-radius: 12px;
bottom: 30px;
margin: auto;
}
.slider__control-buttons,
.slider__control-buttons--selected{
display: inline-block;
width: 15px;
height: 15px;
border-radius: 50%;/*設置為圓形*/
margin: 0 5px;
background-color: white;
cursor: pointer;
}
.slider__control-buttons--selected {
background-color: red;
}
設計 API#
class Slider {
constructor({container}) {
this.container = container;
this.items = Array.from(container.querySelectorAll('.slider__item, .slider__item--selected'));
}
/*
透過選擇器`.slider__item--selected`獲得被選中的元素
*/
getSelectedItem() {
const selected = this.container.querySelector('.slider__item--selected');
return selected;
}
/*
返回選中的元素在items數組中的位置。
*/
getSelectedItemIndex() {
return this.items.indexOf(this.getSelectedItem());
}
slideTo(idx) {
const selected = this.getSelectedItem();
if(selected) { // 將之前選擇的圖片標記為普通狀態
selected.className = 'slider__item';
}
const item = this.items[idx];
if(item) { // 將當前選中的圖片標記為選中狀態
item.className = 'slider__item--selected';
}
}
/*
將下一張圖片標記為選中狀態
*/
slideNext() {
const currentIdx = this.getSelectedItemIndex();
const nextIdx = (currentIdx + 1) % this.items.length;
this.slideTo(nextIdx);
}
/*
將上一張圖片標記為選中狀態
*/
slidePrevious() {
const currentIdx = this.getSelectedItemIndex();
const previousIdx = (this.items.length + currentIdx - 1) % this.items.length;
this.slideTo(previousIdx);
}
}
const container = document.querySelector('.slider');
const slider = new Slider({container});
setInterval(() => {
slider.slideNext();
}, 3000);
實現用戶控制#
我們將構造器修改為下面這樣:
constructor({container, cycle = 3000} = {}) {
this.container = container;
this.items = Array.from(container.querySelectorAll('.slider__item, .slider__item--selected'));
this.cycle = cycle;
const controller = this.container.querySelector('.slider__control');
const buttons = controller.querySelectorAll('.slider__control-buttons, .slider__control-buttons--selected');
controller.addEventListener('mouseover', (evt) => {
const idx = Array.from(buttons).indexOf(evt.target);
if(idx >= 0) {
this.slideTo(idx);
this.stop();
}
});
controller.addEventListener('mouseout', (evt) => {
this.start();
});
/*
註冊slide事件,將選中的圖片和小圓點設置為selected狀態
*/
this.container.addEventListener('slide', (evt) => {
const idx = evt.detail.index;
const selected = controller.querySelector('.slider__control-buttons--selected');
if(selected) selected.className = 'slider__control-buttons';
buttons[idx].className = 'slider__control-buttons--selected';
});
const previous = this.container.querySelector('.slider__previous');
previous.addEventListener('click', (evt) => {
this.stop();
this.slidePrevious();
this.start();
evt.preventDefault();
});
const next = this.container.querySelector('.slider__next');
next.addEventListener('click', (evt) => {
this.stop();
this.slideNext();
this.start();
evt.preventDefault();
});
}
start() {
this.stop();
this._timer = setInterval(() => this.slideNext(), this.cycle);
}
stop() {
clearInterval(this._timer);
}
修改 slideTo 方法,加入自定義事件觸發:
slideTo(idx) {
const selected = this.getSelectedItem();
if (selected) {
selected.className = 'slider__item';
}
const item = this.items[idx];
if (item) {
item.className = 'slider__item--selected';
}
const detail = {index: idx};
const event = new CustomEvent('slide', {bubbles: true, detail});
this.container.dispatchEvent(event);
}
最後將調用過程改成:
const slider = new Slider({container});
slider.start();
組件的插件化#
對於這個圖片輪播組件來說,它的插件化可以是將用戶控制組件從 Slider 組件中剝離出來,做成插件,這樣才能提高 Slider 組件的可擴展性。
class Slider {
constructor({container, cycle = 3000} = {}) {
this.container = container;
this.items = Array.from(container.querySelectorAll('.slider__item, .slider__item--selected'));
this.cycle = cycle;
}
registerPlugins(...plugins) {
plugins.forEach(plugin => plugin(this));
}
/*
透過選擇器`.slider__item--selected`獲得被選中的元素
*/
getSelectedItem() {
const selected = this.container.querySelector('.slider__item--selected');
return selected;
}
/*
返回選中的元素在items數組中的位置。
*/
getSelectedItemIndex() {
return this.items.indexOf(this.getSelectedItem());
}
slideTo(idx) {
const selected = this.getSelectedItem();
if(selected) {
selected.className = 'slider__item';
}
const item = this.items[idx];
if(item) {
item.className = 'slider__item--selected';
}
const detail = {index: idx};
const event = new CustomEvent('slide', {bubbles: true, detail});
this.container.dispatchEvent(event);
}
/*
將下一張圖片標記為選中狀態
*/
slideNext() {
const currentIdx = this.getSelectedItemIndex();
const nextIdx = (currentIdx + 1) % this.items.length;
this.slideTo(nextIdx);
}
/*
將上一張圖片標記為選中狀態
*/
slidePrevious() {
const currentIdx = this.getSelectedItemIndex();
const previousIdx = (this.items.length + currentIdx - 1) % this.items.length;
this.slideTo(previousIdx);
}
start() {
this.stop();
this._timer = setInterval(() => this.slideNext(), this.cycle);
}
stop() {
clearInterval(this._timer);
}
}
/* 小圓點控件 */
function pluginController(slider) {
const controller = slider.container.querySelector('.slider__control');
if(controller) {
const buttons = controller.querySelectorAll('.slider__control-buttons, .slider__control-buttons--selected');
controller.addEventListener('mouseover', (evt) => {
const idx = Array.from(buttons).indexOf(evt.target);
if(idx >= 0) {
slider.slideTo(idx);
slider.stop();
}
});
controller.addEventListener('mouseout', (evt) => {
slider.start();
});
slider.container.addEventListener('slide', (evt) => {
const idx = evt.detail.index;
const selected = controller.querySelector('.slider__control-buttons--selected');
if(selected) selected.className = 'slider__control-buttons';
buttons[idx].className = 'slider__control-buttons--selected';
});
}
}
function pluginPrevious(slider) {
const previous = slider.container.querySelector('.slider__previous');
if(previous) {
previous.addEventListener('click', (evt) => {
slider.stop();
slider.slidePrevious();
slider.start();
evt.preventDefault();
});
}
}
function pluginNext(slider) {
const next = slider.container.querySelector('.slider__next');
if(next) {
next.addEventListener('click', (evt) => {
slider.stop();
slider.slideNext();
slider.start();
evt.preventDefault();
});
}
}
const container = document.querySelector('.slider');
const slider = new Slider({container});
slider.registerPlugins(pluginController, pluginPrevious, pluginNext);
slider.start();
如果,有一天,產品經理又需要對這個組件添加新的用戶控制,比如,添加一個按鈕叫 “試試手氣”,點擊該按鈕,讓輪播圖隨機切換到一張圖片上,那麼們我們只需要這樣做:
在 HTML 代碼中增加 “試試手氣 “這個按鈕:
<button class="lucky">試試手氣</button>
然後創建這個插件:
function pluginLucky(slider) {
const luckyBtn = document.querySelector('.lucky');
if (luckyBtn) {
luckyBtn.addEventListener('click', evt => {
slider.stop();
slider.slideTo(Math.floor(Math.random() * slider.items.length));
slider.start();
evt.preventDefault();
});
}
}
最後將它註冊到 slider 中去即可:
slider.registerPlugins(pluginController, pluginPrevious, pluginNext, pluginLucky);
組件的模板化#
class Slider {
constructor({container, images = [], cycle = 3000} = {}) {
this.container = container;
this.data = images;
this.container.innerHTML = this.render(this.data);
this.items = Array.from(this.container.querySelectorAll('.slider__item, .slider__item--selected'));
this.cycle = cycle;
this.slideTo(0);
}
render(images) {
const content = images.map(image => `
<li class="slider__item">
<img src="${image}"/>
</li>
`.trim());
return `<ul>${content.join('')}</ul>`;
}
registerPlugins(...plugins) {
plugins.forEach((plugin) => {
const pluginContainer = document.createElement('div');
pluginContainer.className = 'slider__plugin';
pluginContainer.innerHTML = plugin.render(this.data);
this.container.appendChild(pluginContainer);
plugin.initialize(this);
});
}
/*
透過選擇器`.slider__item--selected`獲得被選中的元素
*/
getSelectedItem() {
const selected = this.container.querySelector('.slider__item--selected');
return selected;
}
/*
返回選中的元素在items數組中的位置。
*/
getSelectedItemIndex() {
return this.items.indexOf(this.getSelectedItem());
}
slideTo(idx) {
const selected = this.getSelectedItem();
if(selected) {
selected.className = 'slider__item';
}
const item = this.items[idx];
if(item) {
item.className = 'slider__item--selected';
}
const detail = {index: idx};
const event = new CustomEvent('slide', {bubbles: true, detail});
this.container.dispatchEvent(event);
}
/*
將下一張圖片標記為選中狀態
*/
slideNext() {
const currentIdx = this.getSelectedItemIndex();
const nextIdx = (currentIdx + 1) % this.items.length;
this.slideTo(nextIdx);
}
/*
將上一張圖片標記為選中狀態
*/
slidePrevious() {
const currentIdx = this.getSelectedItemIndex();
const previousIdx = (this.items.length + currentIdx - 1) % this.items.length;
this.slideTo(previousIdx);
}
start() {
this.stop();
this._timer = setInterval(() => this.slideNext(), this.cycle);
}
stop() {
clearInterval(this._timer);
}
}
const pluginController = { // 小圓點插件
render(images) { // 隨著圖片數量的增加,小圓點元素也需要增加
return `
<div class="slider__control">
${images.map((image, i) => `
<span class="slider__control-buttons${i === 0 ? '--selected' : ''}"></span>
`).join('')}
</div>
`.trim();
},
initialize(slider) {
const controller = slider.container.querySelector('.slider__control');
if(controller) {
const buttons = controller.querySelectorAll('.slider__control-buttons, .slider__control-buttons--selected');
controller.addEventListener('mouseover', (evt) => {
const idx = Array.from(buttons).indexOf(evt.target);
if(idx >= 0) {
slider.slideTo(idx);
slider.stop();
}
});
controller.addEventListener('mouseout', (evt) => {
slider.start();
});
slider.container.addEventListener('slide', (evt) => {
const idx = evt.detail.index;
const selected = controller.querySelector('.slider__control-buttons--selected');
if(selected) selected.className = 'slider__control-buttons';
buttons[idx].className = 'slider__control-buttons--selected';
});
}
},
};
const pluginPrevious = {
render() {
return '<a class="slider__previous"></a>';
},
initialize(slider) {
const previous = slider.container.querySelector('.slider__previous');
if(previous) {
previous.addEventListener('click', (evt) => {
slider.stop();
slider.slidePrevious();
slider.start();
evt.preventDefault();
});
}
},
};
const pluginNext = {
render() {
return '<a class="slider__next"></a>';
},
initialize(slider) {
const previous = slider.container.querySelector('.slider__next');
if(previous) {
previous.addEventListener('click', (evt) => {
slider.stop();
slider.slideNext();
slider.start();
evt.preventDefault();
});
}
},
};
const images = ['https://p5.ssl.qhimg.com/t0119c74624763dd070.png',
'https://p4.ssl.qhimg.com/t01adbe3351db853eb3.jpg',
'https://p2.ssl.qhimg.com/t01645cd5ba0c3b60cb.jpg',
'https://p4.ssl.qhimg.com/t01331ac159b58f5478.jpg'];
const container = document.querySelector('.slider');
const slider = new Slider({container, images});
slider.registerPlugins(pluginController, pluginPrevious, pluginNext);
slider.start();
模板化之後,代碼的可擴展性得到了進一步的提升。現在,如果我們增加或者減少輪播圖片數量,只需要修改數據中 images 數組的元素個數。如果我們需要或者不需要某個插件,我們只需要修改傳給 registerPlugins () 方法的參數即可。
設計組件框架#
為了提高組件的重用性,我們需要為組件設計一個統一的規範。實現這個統一的規範,我們可以通過設計一套通用的組件機制,並以這套機制為原則構建一個庫。這個通用機制實際上提供了代碼設計和抽象的一套通用規範,而遵循這套規範的基礎庫,實際上就是完整的 UI 組件框架。
class Component {
static name = 'component';
constructor({container, data, parent = null} = {}) {
this.data = data;
this.container = container;
this.container.innerHTML = this.render(this.data);
}
registerSubComponents(...Comps) {
const data = this.data;
const container = this.container;
this.children = this.children || [];
Comps.forEach((Comp) => {
const subContainer = document.createElement('div');
const sub = new Comp({container: subContainer, data, parent: this});
container.appendChild(subContainer);
this.children.push(sub);
});
}
render(data) {
/* abstract */
return '';
}
}
class Slider extends Component {
static name = 'slider';
constructor({container, images = [], cycle = 3000} = {}) {
super({container, data: images});
this.items = Array.from(this.container.querySelectorAll('.slider__item, .slider__item--selected'));
this.cycle = cycle;
this.slideTo(0);
}
render(images) {
const content = images.map(image => `
<li class="slider__item">
<img src="${image}"/>
</li>
`.trim());
return `<ul>${content.join('')}</ul>`;
}
getSelectedItem() {
const selected = this.container.querySelector('.slider__item--selected');
return selected;
}
getSelectedItemIndex() {
return this.items.indexOf(this.getSelectedItem());
}
slideTo(idx) {
const selected = this.getSelectedItem();
if(selected) {
selected.className = 'slider__item';
}
const item = this.items[idx];
if(item) {
item.className = 'slider__item--selected';
}
const detail = {index: idx};
const event = new CustomEvent('slide', {bubbles: true, detail});
this.container.dispatchEvent(event);
}
slideNext() {
const currentIdx = this.getSelectedItemIndex();
const nextIdx = (currentIdx + 1) % this.items.length;
this.slideTo(nextIdx);
}
slidePrevious() {
const currentIdx = this.getSelectedItemIndex();
const previousIdx = (this.items.length + currentIdx - 1) % this.items.length;
this.slideTo(previousIdx);
}
start() {
this.stop();
this._timer = setInterval(() => this.slideNext(), this.cycle);
}
stop() {
clearInterval(this._timer);
}
}
class SliderController extends Component {
static name = 'slider__control';
constructor({container, data, parent: slider}) {
super({container, data});
const buttons = container.querySelectorAll('.slider__control-buttons, .slider__control-buttons--selected');
container.addEventListener('mouseover', (evt) => {
const idx = Array.from(buttons).indexOf(evt.target);
if(idx >= 0) {
slider.slideTo(idx);
slider.stop();
}
});
container.addEventListener('mouseout', (evt) => {
slider.start();
});
slider.container.addEventListener('slide', (evt) => {
const idx = evt.detail.index;
const selected = container.querySelector('.slider__control-buttons--selected');
if(selected) selected.className = 'slider__control-buttons';
buttons[idx].className = 'slider__control-buttons--selected';
});
}
render(images) {
return `
<div class="slider__control">
${images.map((image, i) => `
<span class="slider__control-buttons${i === 0 ? '--selected' : ''}"></span>
`).join('')}
</div>
`.trim();
}
}
class SliderPrevious extends Component {
constructor({container, parent: slider}) {
super({container});
const previous = container.querySelector('.slider__previous');
previous.addEventListener('click', (evt) => {
slider.stop();
slider.slidePrevious();
slider.start();
evt.preventDefault();
});
}
render() {
return '<a class="slider__previous"></a>';
}
}
class SliderNext extends Component {
constructor({container, parent: slider}) {
super({container});
const previous = container.querySelector('.slider__next');
previous.addEventListener('click', (evt) => {
slider.stop();
slider.slideNext();
slider.start();
evt.preventDefault();
});
}
render() {
return '<a class="slider__next"></a>';
}
}
const images = ['https://p5.ssl.qhimg.com/t0119c74624763dd070.png',
'https://p4.ssl.qhimg.com/t01adbe3351db853eb3.jpg',
'https://p2.ssl.qhimg.com/t01645cd5ba0c3b60cb.jpg',
'https://p4.ssl.qhimg.com/t01331ac159b58f5478.jpg'];
const container = document.querySelector('.slider');
const slider = new Slider({container, images});
slider.registerSubComponents(SliderController, SliderPrevious, SliderNext);
slider.start();