如何写好JavaScript | 青训营笔记

68 阅读8分钟

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

写好JS的一些原则

  • 各司其责
    • 让HTML、CSS、JavaScript职能分离
  • 组件封装
    • 好的UI组件具备封装性、正确性、扩展性、复用性
  • 过程抽象
    • 应用函数式编程思想

各司其责 例-深夜食堂

写一段JS,控制一个网页,让它支持浅色和深色两种浏览模式 image.png image.png

版本1

点击按钮,如果按钮是'🌞',将背景颜色换成黑色、字体颜色换成白色、按钮图标换成'🌜';如果按钮是'🌜',将背景颜色换成白色、字体颜色换成黑色、按钮图标换成'🌞'。

const btn = document.getElementById('modeBtn');
  btn.addEventListener('click', (e) => {
    const body = document.body;
    if(e.target.innerHTML === '🌞') {
      body.style.backgroundColor = 'black';
      body.style.color = 'white';
      e.target.innerHTML = '🌜';
    } else {
      body.style.backgroundColor = 'white';
      body.style.color = 'black';
      e.target.innerHTML = '🌞';
    }
  });

版本1的不足之处:使用JS控制CSS,不便于修改需求。

版本2

使用 class 来表示状态。默认class为白天,当点击按钮时,如果为白天,则将className切换为'night';如果为'night',则切换回白天

const btn = document.getElementById('modeBtn');
  btn.addEventListener('click', (e) => {
    const body = document.body;
    if(body.className !== 'night') {
      body.className = 'night';
    } else {
      body.className = '';
    }
  });
版本3 纯CSS实现

在界面内隐藏一个checkbox,当checkbox被选择后,选择它的兄弟节点content,将content的样式改为深夜模式。

  <input id="modeCheckBox" type="checkbox">
  <div class="content">
    <header>
      <label id="modeBtn" for="modeCheckBox"></label>
      <!-- for绑定checkbox,实现点击label后达到点击checkbox的效果 -->
      <h1>深夜食堂</h1>
    </header>
    <main>
      <div class="pic">
        <img src="https://p2.ssl.qhimg.com/t0120cc20854dc91c1e.jpg">
      </div>
      <div class="description">
        <p>
            这是一间营业时间从午夜十二点到早上七点的特殊食堂。这里的老板,不太爱说话,却总叫人吃得热泪盈眶。在这里,自卑的舞蹈演员偶遇隐退多年舞界前辈,前辈不惜讲述自己不堪回首的经历不断鼓舞年轻人,最终令其重拾自信;轻言绝交的闺蜜因为吃到共同喜爱的美食,回忆起从前的友谊,重归于好;乐观的绝症患者遇到同命相连的女孩,两人相爱并相互给予力量,陪伴彼此完美地走过了最后一程;一味追求事业成功的白领,在这里结交了真正暖心的朋友,发现真情比成功更有意义。食物、故事、真情,汇聚了整部剧的主题,教会人们坦然面对得失,对生活充满期许和热情。每一个故事背后都饱含深情,情节跌宕起伏,令人流连忘返 [6]  。
        </p>
      </div>
    </main>
  </div>
  #modeCheckBox {
    display: none;
    /*隐藏checkbox*/
  }

  #modeCheckBox:checked + .content {
    background-color: black;
    color: white;
    transition: all 1s;
  }

结论:

  • HTML/CSS/JS 各司其责
  • 应当避免不必要的由 JS 直接操作样式
  • 可以用 class 来表示状态
  • 纯展示类交互寻求零 JS 方案

组件封装 例-轮播图

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

结构设计:HTML
  • 轮播图是一个典型的列表结构,可以使用无序列表ul元素来实现。
  • 在图片的左右添加上一张/下一张的箭头
  • 在图片的底部添加四个圆点表示图片的顺序以及当前所选中的图片
    <div id="my-slider" class="slider-list">
      <ul>
        <li class="slider-list__item--selected">
          <img src="https://p5.ssl.qhimg.com/t0119c74624763dd070.png">
        </li>
        <li class="slider-list__item">
          <img src="https://p4.ssl.qhimg.com/t01adbe3351db853eb3.jpg">
        </li>
        <li class="slider-list__item">
          <img src="https://p2.ssl.qhimg.com/t01645cd5ba0c3b60cb.jpg">
        </li>
        <li class="slider-list__item">
          <img src="https://p4.ssl.qhimg.com/t01331ac159b58f5478.jpg">
        </li>
      </ul>
      <a class="slide-list__next"></a>
      <a class="slide-list__previous"></a>
      <div class="slide-list__control">
         <span class="slide-list__control-buttons--selected"></span>
         <span class="slide-list__control-buttons"></span>
         <span class="slide-list__control-buttons"></span>
         <span class="slide-list__control-buttons"></span>
      </div>
    </div>
展现效果:CSS
  • 使用 CSS 绝对定位将图片重叠在同一个位置
  • 轮播图切换的状态使用修饰符(modifier)
  • 轮播图的切换动画使用 CSS transition
  #my-slider{
    position: relative;
    width: 790px;
  }

  .slider-list ul{
    list-style-type:none;
    position: relative;
    padding: 0;
    margin: 0;
  }

  .slider-list__item,
  .slider-list__item--selected{
    
    /*绝对定位将图片重叠在同一个位置*/
    position: absolute;
    
    /*在1秒内过渡到完全透明*/
    transition: opacity 1s;
    
    /*完全透明*/
    opacity: 0;
    
    text-align: center;
  }

  .slider-list__item--selected{
    transition: opacity 1s;
    opacity: 1;
  }
  .slide-list__control{
  position: relative;
  display: table;
  background-color: rgba(255, 255, 255, 0.5);
  padding: 5px;
  border-radius: 12px;
  bottom: 30px;
  margin: auto;
}

.slide-list__next,
.slide-list__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;
  background: transparent;
  color: white;
  background: rgba(0,0,0,0.2);
  cursor: pointer;
  opacity: 0;
  transition: opacity .5s;
}

.slide-list__previous {
  left: 0;
}

.slide-list__next {
  right: 0;
}

#my-slider:hover .slide-list__previous {
  opacity: 1;
}

#my-slider:hover .slide-list__next {
  opacity: 1;
}

.slide-list__previous:after {
  content: '<';
}

.slide-list__next:after {
  content: '>';
}

.slide-list__control-buttons,
.slide-list__control-buttons--selected{
  display: inline-block;
  width: 15px;
  height: 15px;
  border-radius: 50%;
  margin: 0 5px;
  background-color: white;
  cursor: pointer;
}

.slide-list__control-buttons--selected {
  background-color: red;
}
行为设计:JS
  • API(功能)
    • Slider
      • +getSelectedItem()
      • +getSelectedItemIndex()
      • +slideTo()
      • +slideNext()
      • +slidePrevious()
  • Event(控制流)
    • 使用自定义事件来解耦
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();
        });
      
        this.container.addEventListener('slide', evt => {
          //监听slide事件,获取选中图片下标并将相应的圆点置为被选中(变成红色)
          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(){ //得到当前所选中的图片
      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}
      //派发slide事件
      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 slider = new Slider('my-slider');
  slider.start();
重构

之前的实现方式控制点和组件绑定在一起,不够灵活;且构造函数过于复杂。

插件化
  • 将控制元素抽取成插件
  • 插件与组件之间通过依赖注入方式建立联系
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){ //将插件注册进plugin
    plugins.forEach(plugin => plugin(this));
  }
  
  //······同上

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();

优势:

  • 简化了构造函数。
  • 可以更方便的修改需求,如:若不需要底部圆点,只需要将相应的HTML代码删掉后取消注册: slider.registerPlugins(pluginPrevious, pluginNext);即可实现。
    • 例-新插件:
    <button id="randomGet">手气不错</button>
   
    function pluginRandomGet(slider){
    randomGet.addEventListener('click', evt => {
      const idx = Math.floor(slider.items.length * Math.random());
      slider.stop();
      slider.slideTo(idx);
      slide.start();
    });
  }
  
    slider.registerPlugins(pluginRandomGet, pluginController, pluginPrevious, pluginNext);
模板化

插件化的缺陷:取消注册插件后还需要在HTML中删除代码。

  • 将HTML模板化,更易于扩展 image.png
<div id="my-slider" class="slider-list"></div>
constructor(id, opts = {images:[], cycle: 3000}){
  this.container = document.getElementById(id);
  this.options = opts;
    
  //将render()返回的innerHTML插入container
  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;
    
  //将得到的image生成li
  const content = images.map(image => `
    <li class="slider-list__item">
      <img src="${image}"/>
    </li>    
  `.trim());
    
  //拼接到ul里
  return `<ul>${content.join('')}</ul>`;
}
  
//注册插件
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 pluginPrevious = {
  
  //render()内容渲染
  render(){
    return `<a class="slide-list__previous"></a>`;
  },
    
  //action()初始化
  action(slider){
    const previous = slider.container.querySelector('.slide-list__previous');
    if(previous){
      previous.addEventListener('click', evt => {
        slider.stop();
        slider.slidePrevious();
        slider.start();
        evt.preventDefault();
      });
    }  
  }
};
  
//剩余插件同理
  
const slider = new Slider('my-slider', {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'], cycle:3000});

优势:

插件变更更加方便灵活,只需取消注册即可完成功能和样式的同步消除。

不足之处:

未将CSS也模板化。

抽象化(组件框架)
  • 将组件通用模型抽象出来 image.png
  • 抽象组件:Component
    • registerPlugins(..plugins) 注册插件
    • render() 内容渲染
  • Slider 继承抽象组件
  • SliderPlugin 完成渲染
class Component{
    constructor(id, opts = {name, 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 = `.${name}__plugin`;
        pluginContainer.innerHTML = plugin.render(this.options.data);
        this.container.appendChild(pluginContainer);
        
        plugin.action(this);
      });
    }
    
    //渲染,抽象方法
    render(data) {
      /* abstract */
      return ''
    }
  }
  
  class Slider extends Component{
  //继承后重写render()方法
  }
  
  const slider = new Slider('my-slider', {name: 'slide-list', data: ['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'], cycle:3000});

优势:

组件设计简单,每一个组件都是由组件 + 控制插件组成

缺陷:

没有考虑嵌套:将子组件作为父组件的插件使用

过程抽象

  • 用来处理局部细节控制的一些方法
  • 函数式编程思想的基础应用

image.png

例-操作次数限制
  • 一些异步交互
  • 一次性的HTTP请求

jr3m1-zmr59.gif

const list = document.querySelector('ul');
const buttons = list.querySelectorAll('button');
buttons.forEach((button) => {
  button.addEventListener('click', ((evt) => {
    const target = evt.target;
    
    //将任务状态置为‘完成’
    target.parentNode.className = 'completed';
    
    //动画:两秒后remove
    setTimeout(() => {
      list.removeChild(target.parentNode);
    }, 2000);
  }));
});

问题描述:当任务还没有删除时,连续点击,执行多次remove,但因为第一次点击时任务已经被移除,导致后面的操作报错: image.png 解决方法: 将过程封装成一个once高阶函数

//第一次调用时,fn != null,返回ret同时将fn设为null;
//第二次调用时,fn == null,方法只会被调用一次
function once(fn) {
  return function(...args) {
    if(fn) {
      const ret = fn.apply(this, args);
      fn = null;
      return ret;
    }
  }
}

button.addEventListener('click', once((evt) => {
//······
}));

Once: 为了能够让“只执行一次“的需求覆盖不同的事件处理,我们可以将这个需求剥离出来。这个过程我们称为过程抽象

高阶函数HOF

image.png

  • 以函数作为参数
  • 以函数作为返回值
  • 常用于作为函数装饰器
//等价函数:
function HOF0(fn) { //接受fn函数作为参数
    return function(...args) { //返回fn
      return fn.apply(this, args);
    }
  }

常用高阶函数

  • Once 操作次数限制
  • Throttle 节流函数
  • Debounce 防抖函数
  • Consumer/2 同步调用变异步调用;延时调用
  • Iterative 可迭代方法
为什么要使用高阶函数

高阶函数一般都是纯函数,使用高阶函数可以大大减少使用非纯函数的可能性。纯函数更便于测试,系统可维护性更高。

编程范式

image.png image.png

命令式 Imperative

详细的命令机器怎么(How)去处理一件事情以达到你想要的结果(What)

  • 面向过程
  • 面向对象
声明式 Declarative

只告诉你想要的结果(What),机器自己摸索过程(How)

  • 逻辑式
  • 函数式

image.png

  • 命令式
switcher.onclick = function(evt){
  if(evt.target.className === 'on'){
    evt.target.className = 'warn';
  }else if(evt.target.className === 'warn'){
    evt.target.className = 'off';
  }else{
    evt.target.className = 'on';
  }
}
  • 声明式
function toggle(...actions){
  return function(...args){
    let action = actions.shift();
    actions.push(action);
    return action.apply(this, args);
  }
}

switcher.onclick = toggle(
  evt => evt.target.className = 'on',
  evt => evt.target.className = 'warn',
  evt => evt.target.className = 'off'
);