前言
《如何写‘好’javascript》这门课是由360技术专家月影老师讲的。
说实话,我一直在纠结要不要写关于js的文章,因为对于js来说,我的实际经验不足,更不要说面向对象编程与函数式编程了,对于过程抽象与行为抽象也没有深入的理解,但想想还是觉得应该分享出来,并且我尽量原汁原味的阐述这门课的内容,尽量不加入自己主观理解,因为对于没有实际经验的我来说,如果添加自己主观的理解只能误导读者,好了,不费话了~
一、关灯吃面
需求:
- 点击红色按钮
- 背景变成黑色
- 字体color由黑色变成白色
- 红色按钮变成绿色
1.1 版本1
light.onclick = function(evt) { if(light.style.backgroundColor !== 'green'){ document.body.style.backgroundColor = '#000'; document.body.style.color = '#fff'; light.style.backgroundColor = 'green'; }else{ document.body.style.backgroundColor = ''; document.body.style.color = ''; light.style.backgroundColor = ''; }}
对于我来说,要是让我完成这个需求,大概应该就写成这样吧^_^,
想想这样写好不好呢?
答案肯定是不好的。
这样写的问题:
- 用js直接去修改了元素的样式。
- 并且代码只能看出修改了一些元素的样式,看不出这坨代码需要完成哪些需求。
- 假设:如果以后想改需求了,比如开灯时字体变为红色,或者需要添加一些功能,那我就得去重新看代码,去改这一坨代码,这样的话,维护起来就非常难。
1.2 版本2:
lightButton.onclick = function(evt) { if(main.className === 'light-on'){ main.className = 'light-off'; }else{ main.className = 'light-on'; }}
这回代码语义化就比较强了,通过js去修改className而不是用js来直接修改style,这样写会比较好一点。
1.3 版本3:其他思路
今天回到家, 煮了点面吃, 一边吃面一边哭, 泪水滴落在碗里, 没有开灯。
这么写的思路就是不使用js,而是通过input和label关联来切换状态。
二、复杂的UI组件的设计
这是大家最熟悉不过的轮播图组件了,如果用面向过程的写法,可能会出现很多bug,那么如何实现才是最好的呢?
2.1 步骤1:整体思路
整体思路
- 图片结构是一个列表型结构,所以主体用
<ul>
和<li>
- 使用 css 绝对定位将图片重叠在同一个位置
- 轮播图切换的状态使用修饰符(modifier)
- 轮播图的切换动画使用 css transition
2.2 步骤2: API设计
具体实现:
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()); } // 切换到第index张图片 slideTo(idx){ const selected = this.getSelectedItem(); if(selected){ selected.className = 'slider-list__item'; } const item = this.items[idx]; if(item){ item.className = 'slider-list__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); }}// 通过new来实例化const slider = new Slider('my-slider');setInterval(() => { slider.slideNext()}, 3000)
2.3 步骤3:控制流设计 (下方小圆点与左右按钮设计)
控制结构
自定义事件
const detail = {index: idx} const event = new CustomEvent('slide', {bubbles:true, detail}) this.container.dispatchEvent(event)
因为下方原点与图片自动切换的下标(index)是一致的,所以可以通过事件机制,在图片slide时候直接给container派发一个事件,这样的话呢,通过container去监听这个事件,去更新控制结构上小圆点的状态。
具体实现:
class Slider{ constructor(id, cycle = 3000){ this.container = document.getElementById(id); this.items = this.container.querySelectorAll('.slider-list__item, .slider-list__item--selected'); this.cycle = cycle; const controller = this.container.querySelector('.slide-list__control'); if(controller){ const buttons = controller.querySelectorAll('.slide-list__control-buttons, .slide-list__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事件 this.container.addEventListener('slide', evt => { // 拿到slide事件传来的index const idx = evt.detail.index const selected = controller.querySelector('.slide-list__control-buttons--selected'); if(selected) selected.className = 'slide-list__control-buttons'; buttons[idx].className = 'slide-list__control-buttons--selected'; }) } const previous = this.container.querySelector('.slide-list__previous'); if(previous){ previous.addEventListener('click', evt => { this.stop(); this.slidePrevious(); this.start(); evt.preventDefault(); }); } const next = this.container.querySelector('.slide-list__next'); if(next){ next.addEventListener('click', evt => { this.stop(); this.slideNext(); this.start(); evt.preventDefault(); }); } } getSelectedItem(){ let selected = this.container.querySelector('.slider-list__item--selected'); return selected } getSelectedItemIndex(){ return Array.from(this.items).indexOf(this.getSelectedItem()); } slideTo(idx){ let selected = this.getSelectedItem(); if(selected){ selected.className = 'slider-list__item'; } let item = this.items[idx]; if(item){ item.className = 'slider-list__item--selected'; } const detail = {index: idx} const event = new CustomEvent('slide', {bubbles:true, detail}) this.container.dispatchEvent(event) } slideNext(){ let currentIdx = this.getSelectedItemIndex(); let nextIdx = (currentIdx + 1) % this.items.length; this.slideTo(nextIdx); } slidePrevious(){ let currentIdx = this.getSelectedItemIndex(); let 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 slider = new Slider('my-slider');slider.start();
这个实现的构造函数会复杂一些,但是把timer定时器也封装进去了,会有轮播的时间默认为3秒钟,同样的也是获得container,items,cycle(时间)通过事件机制将控制流中的小圆点与图片联动起来。并且还判断了controler是否存在,假如以后我们不需要小圆点这个功能了,我们只需要把html中相关的结构去掉,js也不会报错,但是这里还有一个优化的点就是slider与controler之间有着比较强的耦合度。
2.4 控制流设计原则
为什么要用到事件机制呢?因为要降低结构之间的耦合度,如果不这样做的话,我们需要做双向的操控的。
举个栗子?
比如我们要添加一个需求:显示当前index。
只需要这样做:
- 结构中添加
第0张
- js中添加
document.addEventListener('slider', (evt) => { other.innerHTML = `第${evt.detail.index}张`})
三、这样是不是就可以交差了呢?
其实还是有很大的改动空间的,比如上面的代码在构造函数的代码量特别多,slider与controler的耦合度比较大,如何降低它们之间的耦合度呢?
3.1 优化1:插件/依赖注入
class Slider{ constructor(id, cycle = 3000){ this.container = document.getElementById(id); this.items = this.container.querySelectorAll('.slider-list__item, .slider-list__item--selected'); this.cycle = cycle; } registerPlugins(...plugins){ plugins.forEach(plugin => plugin(this)); } getSelectedItem(){ const selected = this.container.querySelector('.slider-list__item--selected'); return selected } getSelectedItemIndex(){ return Array.from(this.items).indexOf(this.getSelectedItem()); } slideTo(idx){ const selected = this.getSelectedItem(); if(selected){ selected.className = 'slider-list__item'; } const item = this.items[idx]; if(item){ item.className = 'slider-list__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); } addEventListener(type, handler){ this.container.addEventListener(type, handler) } start(){ this.stop(); this._timer = setInterval(()=>this.slideNext(), this.cycle); } stop(){ clearInterval(this._timer); }}function pluginController(slider){ const controller = slider.container.querySelector('.slide-list__control'); if(controller){ const buttons = controller.querySelectorAll('.slide-list__control-buttons, .slide-list__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.addEventListener('slide', evt => { const idx = evt.detail.index const selected = controller.querySelector('.slide-list__control-buttons--selected'); if(selected) selected.className = 'slide-list__control-buttons'; buttons[idx].className = 'slide-list__control-buttons--selected'; }); } }function pluginPrevious(slider){ const previous = slider.container.querySelector('.slide-list__previous'); if(previous){ previous.addEventListener('click', evt => { slider.stop(); slider.slidePrevious(); slider.start(); evt.preventDefault(); }); } }function pluginNext(slider){ const next = slider.container.querySelector('.slide-list__next'); if(next){ next.addEventListener('click', evt => { slider.stop(); slider.slideNext(); slider.start(); evt.preventDefault(); }); } }const slider = new Slider('my-slider');slider.registerPlugins(pluginController, pluginPrevious, pluginNext);slider.start();
这样做的好处:比如我们不想要controler这个组件了,直接删掉插件与html对应结构,其他的功能还是可以正常使用。
3.2 优化2:改进插件/模板化
上面的代码还不是特别的优雅,当我们不想要一个功能时,需要删除html结构与js代码,如果用模板化,只需要修改js即可。
render方法会传data数据,负责构造html结构
action方法会注入component对象,负责初始化这个对象,添加事件、行为。这样我们的html结构只有
class Slider{ constructor(id, opts = {images:[], cycle: 3000}){ this.container = document.getElementById(id); this.options = opts; this.container.innerHTML = this.render(); this.items = this.container.querySelectorAll('.slider-list__item, .slider-list__item--selected'); this.cycle = opts.cycle || 3000; this.slideTo(0); } render(){ const images = this.options.images; const content = images.map(image => `
- ${content.join('')}
这样做的好处就是我们可以随意修改这个组件的功能,如果不想要两边的按钮或者控制流的小圆点,只需要修改注册插件即可。
插件化/模板化这种做法还有一个缺点就是如果我们修改插件时,我们直接append到组件里,可能只修改了一点点代码,最后导致整个dom都刷新了,这就是为什么现在一些主流框架采用虚拟dom的方式,通过diff算法来局部修改dom。
3.3 优化3:组件模型抽象
最终实现:
class Component{ constructor(id, opts = {data:[]}){ this.container = document.getElementById(id); this.options = opts; this.container.innerHTML = this.render(opts.data); } registerPlugins(...plugins){ plugins.forEach(plugin => { const pluginContainer = document.createElement('div'); pluginContainer.className = '.slider-list__plugin'; pluginContainer.innerHTML = plugin.render(this.options.data); this.container.appendChild(pluginContainer); plugin.action(this); }); } render(data) { /* abstract */ return '' }}class Slider extends Component{ constructor(id, opts = {data:[], cycle: 3000}){ super(id, opts); this.items = this.container.querySelectorAll('.slider-list__item, .slider-list__item--selected'); this.cycle = opts.cycle || 3000; this.slideTo(0); } render(data){ const content = data.map(image => `
- ${content.join('')}
四、谈一谈‘抽象’
我们从最初的需求开始一步一步的得到最终组件抽象的这个模型,我们理清楚了组件和插件的关系,以及他们之间应该怎样完成渲染,这里面很重要的是我们一步步的在做抽象,一步步的抽象出来这些元素,然后一步步的拆解这些元素之间的依赖关系,尽量把他们独立出来,不管组件也好还是插件也好,我们都希望未来当我们这个ui、交互有一小部分的变化的时候,我们只要去修改、重建这部分变化所涉及到的插件或者组件就可以了,而不用动整个这个代码结构,这样让我们代码的健壮性和可维护性就大大的增强了,我们就可以把这个组件发布出来了。
4.1 回顾一下
- 组件的结构
- 组件的api设计
- 控制流的设计
这三个东西设计完之后,通过一些技巧,把这个组件这三个部分给封装好,并且把他们抽象出来,降低他们的耦合度。比如我们用到了依赖注入技巧、自定义事件技巧、模板化的技巧,这些技巧都可以让我们设计出低耦合度的ui组件。
五、最后
我一直觉得一篇文章过多的代码会让读者感到视觉疲劳,但实在是没有需要修改的地方,非常建议大家一步步的敲一遍,深刻体会月影大大写的javascript是多么的优雅?~~