如何写好JS | 青训营笔记

116 阅读7分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 3 天

如何写好JS的三大板块:各司其职、组件封装、过程抽象

各司其职

案例:点击实现背景颜色深色和浅色(夜间/白天)的切换

image.png image.png

版本1

 <div class="box">
     <button class="modeBtn">🌞</button>
     <p>一段文字一段文字一段文字一段文字一段文字一段文字一段文字一段文字</p>
 </div>
 ​
 <script>
     const btn = document.querySelector('.modeBtn');
     const box = document.querySelector('.box');
     btn.addEventListener('click',(e)=>{
         if(e.target.innerHTML === '🌞'){
             box.style.backgroundColor = 'black';
             box.style.color = 'white';
             e.target.innerHTML = '🌛';
         } else {
             box.style.backgroundColor = 'white';
             box.style.color = 'black';
             e.target.innerHTML = '🌞';
         }
     })
 </script>

版本二

 <style>
 .night {
   background-color: black;
   color:white;
 }
 .modeBtn::after {
   content: '🌞';
 }
 .night .modeBtn::after {
   content: '🌛';
 }
 </style>
 ​
 <script>
     const btn = document.querySelector('.modeBtn');
     const box = document.querySelector('.box');
     btn.addEventListener('click', (e) => {
       if (!box.classList.contains('night')) {
         box.className += ' night';
       } else {
         box.classList.remove('night');
       }
     })
 </script>

版本三

 <input type="checkbox" id="modeCheckBox">
 <div class="box">
   <label class="modeBtn" for="modeCheckBox"></label>
   <p>一段文字一段文字一段文字一段文字一段文字一段文字一段文字一段文字</p>
 </div>
 ​
 <style>
 .modeBtn::after {
     content: '🌞';
 }
 #modeCheckBox {
     display: none;
 }
 #modeCheckBox:checked + .box{
     background-color: black;
     color:white;
 }
 #modeCheckBox:checked + .box .modeBtn::after{  
     content: '🌛';
 }
 </style>

三个版本的对比

类别\版本版本一版本二版本三
HTML<button><button><input type="checkbox"> <label>
CSS仅仅初始化提供需要的类直接实现业务需求
Javascript直接操作CSS样式操作元素的类(状态)无JS

分析

  • 版本一,JS做了应由CSS做的事情(修改样式),若需求发生变化(如改为红绿颜色的切换),则需要修改JS的代码;其次,这样写不利于其它人明白业务的需求(只知道做了样式的改变)
  • 版本二,JS通过元素的类来改变样式,需求变化直接修改CSS不用改JS,易于操作和理解
  • 版本三,此功能仅仅需要样式改变,没有其它行为逻辑,CSS直接实现,不需要JS,真香!

总结

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

组件封装

案例:电商网站轮播图

image.png

功能实现

基本方法

  • 结构设计HTML
  • 展现效果CSS
  • 行为设计JS--API(功能) / Event(控制流)

HTML结构1

    <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="slider-list__previous"></a>
        <a class="slider-list__next"></a>
        <!-- 控制条 -->
        <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>

1. 四张图片实现轮播 -> API设计

image.png

class Slider {
    constructor(id, cycle = 3000) {
        this.container = document.getElementById(id);
        this.items = document.querySelectorAll('.slider-list__item, .slider-list__item--selected');
        this.cycle = cycle;
    }
    getSelectedItem() {
        let selected = this.container.querySelector('.slider-list__item--selected');
        return selected;
    }
    getSelectedItemIndex() {
        // this.items原本不是数组
        return Array.from(this.items).indexOf(this.getSelectedItem());
    }
    slideTo(imgIdx) {
        let selected = this.getSelectedItem();
        // 此处两个if是健壮性考虑
        if (selected) {
            selected.className = 'slider-list__item';
        }
        let item = this.items[imgIdx];
        if (item) {
            item.className = 'slider-list__item--selected';
        }
    }
    slideNext() {
        // 取余数,不直接+1,防止数值超过length
        let currentIdx = this.getSelectedItemIndex();
        let nextIdx = (currentIdx + 1) % this.items.length;
        this.slideTo(nextIdx);
    }
    slidePrevious() {
        const currentIdx = this.getSelectedItemIndex();
        // +length防止数值小于0(负数取余数为负数)
        const previousIdx = (currentIdx - 1 + this.items.length) % this.items.length;
        this.slideTo(previousIdx);
    }
    // 计时器
    start() {
        this.stop();
        // _下划线:私有变量 
        this._timer = setInterval(() => this.slideNext(), this.cycle);
    }
    stop() {
        clearInterval(this._timer);
    }
}

2. 左右按键点击切换图片
👉添加在构造函数内

// 左按键
const previous = this.container.querySelector('.slider-list__previous');
if (previous) {
    previous.addEventListener('click', evt => {
        this.stop();
        this.slidePrevious();
        this.start();
        evt.preventDefault();
    });
}
// 右按键
const next = this.container.querySelector('.slider-list__next');
if (next) {
    next.addEventListener('click', evt => {
        this.stop();
        this.slideNext();
        this.start();
        evt.preventDefault();
    });
}

3. 控制条绑定图片并跟随鼠标选中切换图片

小圆点需要与图片滑动绑定的,为了避免绑定后,控制条与图片耦合,使用自定义事件来解耦。若没有自定义事件,那么写在自定义事件下的代码就写在了mouseover里,即mouseover 里做了两件事,一个是滑倒指定图片,一个是小圆点对应变化。将小圆点对应变化单独拿出来就实现了解耦

👉添加在构造函数内

// 控制条
const controller = this.container.querySelector('.slider-list__control');
if (controller) {
    const buttons = controller.querySelectorAll('.slider-list__control-buttons--selected, .slider-list__control-buttons');
    controller.addEventListener('mouseover', evt => {
        const btnIdx = Array.from(buttons).indexOf(evt.target);
        if (btnIdx >= 0) {
            this.slideTo(btnIdx);
            this.stop();
        }
    });
    controller.addEventListener('mouseout', evt => {
        this.start();
    });
    this.container.addEventListener('slide', evt => {
        const idx = evt.detail.index;
        const selected = controller.querySelector('.slider-list__control-buttons--selected');
        if (selected) selected.className = 'slider-list__control-buttons';
        buttons[idx].className = 'slider-list__control-buttons--selected';
    });
}

👉 添加在slideTo()

// 添加自定义事件
const detail = { index: imgIdx }
const event = new CustomEvent('slide', { bubbles: true, detail });
// 手动触发:小圆点跟随变化
this.container.dispatchEvent(event);

优化1: 插件化

问题: 不够灵活

  • 构造函数过于复杂
  • 比如要删除左右按键,HTML, CSS和JS的代码都需要修改
  • 整个控件的元素都是绑定在一起的

解决: 解耦

  • 将控制元素抽取成插件
  • 插件与组件之间通过依赖注入方式建立联系,脱离构造函数
  • 好处: Slider不用关心插入的是什么插件,可以快速地增删改插件

👉 Slider类内的API

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

👉 各插件: 注册事件

function pluginController(slider) {...}
function pluginPrevious(slider) {...}
function pluginNext(slider) {...}

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

优化2: 模板化

问题:

  • html的页面结构是写死的,不符合JS的UI组件中-数据驱动的需求
  • 如,JS删除某一个插件,还需要到html中删除

解决:

  • 依据数据生成HTML模板,即将HTML模板化,更易于扩展
  • 如图片数量发生了变化,需要手动修改HTML文件
  • 好处:若不需要某个插件功能,直接注释掉,不需要再做其它修改

image.png

HTML结构2

 <body>
     <!-- 只剩一个div啦 -->
     <div id="my-slider" class="slider-list"></div>
 </body>

👉在Slider类添加render()方法,并渲染HTML模板

 constructor(id, opts = { images: [], cycle: 3000 }) {
     // ...
     this.container.innerHTML = this.render();
     // ...
 }
 render() {
     const images = this.options.images;
     const content = images.map(image => `
         <li class="slider-list__item">
             <img src="${image}"/>
         </li>`.trim());  // trim() 从字符串的两端清除空白字符,返回一个新的字符串
     return `<ul>${content.join('')}</ul>`;
 }

👉在插件中同样添加render()方法,原来的方法放到action()

// 控制条
 const pluginController = {
     // 有几张图片生成几个span, 第一个span加上--selected
     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 pluginPrevious = {
     render() {return `<a class="slider-list__previous"></a>`;},
     action(slider) {...}
 }
 const pluginNext = {
     render() {return `<a class="slider-list__next"></a>`;},
     action(slider) {...}
 }

👉修改插件注入

 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://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});
 ​
 slider.registerPlugins(pluginPrevious, pluginNext, pluginController);
 slider.start();

优化3: 抽象化

  • 问题:Slider组件还可以再抽象为通用组件
  • 解决:抽象出通用组件,该组件做两件事: 一是可插入插件,二是可以渲染HTML模板
  • 分析: 这样的抽象方法(组件框架)
    • 好处: 组件的设计简单, 任何的组件都是由这两部分组成的
    • 不足: 没有考虑嵌套, 比如子组件作为父组件的插件

image.png

 class Component {
     constructor(id, opts = { dataName, data:[]}) {
         this.container = document.getElementById(id);
         this.options = opts;
         this.container.innerHTML = this.render(opts.data);
     }
     render(data){
         // abstract
         return '';
     }
     registerPlugins(...plugins) {...}

👉 Slider类继承抽象组件,并实现render()抽象方法

 class Slider extends Component {
     constructor(id, opts = { dataName:'slider-list', data: [], cycle: 3000 }) {
         super(id, opts);
         // ...
         this.cycle = opts.cycle || 3000;  // 新加的参数
     }
     render(data) {...}
     // ...
 }

👉最后实例化

const slider = new Slider('my-slider', {images:[...],cycle:3000});

总结

  • 组件设计的原则: 封装性, 正确性, 扩展性, 复用性
  • 实现组件的步骤: 结构设计, 展现效果, 行为设计
  • 三次重构: 插件化, 模板化, 抽象化(组件框架)

过程抽象

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

概念

  • 纯函数:一个函数的返回结果只依赖于它的参数,并且在执行过程里面没有副作用。
  • 闭包(closure):闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。

例子:操作次数限制

  • 描述:任务完成后点击☑️,删除已完成的任务
image.png image.png image.png
  • 问题:完成的任务多次点击,报错

image.png

  • 原因:元素只能被移除一次,即该click事件只能执行一次
  • 解决1:DOM事件自带功能:once:true
  • 解决2:把“只能执行一次”的功能剥离出来(过程抽象),使它能够覆盖不同的事件处理。
 function once(fn) {
     // outer scape closure
     return function (...args) {
         // inner scape closure
         // 第一次执行,函数存在,再次执行函数不存在
         if (fn) {
             // 执行函数
             const ret = fn.apply(this, args);
             fn = null;
             return ret;
         }
     }
 }

高阶函数

高阶函数(Higher-order function, HOF)

  • 对其他函数进行操作的函数,操作可以是将它们作为参数,或者是返回它们。
  • 常用于函数装饰器。
 function HOF0(fn){
     return function(..args) {
         return fn.apply(this, args);
     }
 }

Q:为什么要使用高阶函数?

A: 帮助我们减少使用非纯函数。在一个库中我们应尽量避免使用非纯函数,从而提高代码的可维护性

✔常见的高阶函数

  • Throttle 节流函数,避免高频率的事件触发(性能开销过大)
 每500毫秒可记录一次
 <button>点我</button>
 <div>0</div>
 ​
 <script>
     function throttle(fn, time = 500) {
         let timer;
         return function (...args) {
             if (timer == null) {
                 fn.apply(this, args);
                 // 间隔一档时间把timer设置为空,此时才可以执行函数
                 timer = setTimeout(() => {
                     timer = null;
                 }, time);
             }
         }
     }
 ​
     const btn = document.querySelector('button');
     const div = document.querySelector('div');
     btn.addEventListener('click', throttle(evt => {
         div.innerHTML = parseInt(div.innerHTML) + 1;
         div.className = 'fade';
         setTimeout(() => div.className = '', 250);
     }));
 </script>
  • Debounce 防抖函数,例如自动保存功能,直播点赞
 function debounce(fn, during){
   const dur = during || 100;
   let timer;
   return function(...args){
     // 每次执行都会clear
     clearTimeout(timer);
     // 不请求fn的一顿时间后才执行fn
     timer = setTimeout(() => {
       fn.apply(this, args);
     }, dur);
   }
 }

编程范式

✔命令式 VS 声明式

👉JS兼具这两种范式

  • 命令式
    • 面向过程 or 面向对象
    • 强调怎么做(how)
 // 数组值翻倍
 let list = [1, 2, 3, 4];
 let mapl = [];
 for(let i = 0; i < list.length; i++) {
     mapl.push(list[i] * 2);
 }
  • 声明式
    • 逻辑式 or 函数式
    • 强调做什么(what)
    • 比命令式具有更强的可拓展性
 // 数组值翻倍
 let list = [1, 2, 3, 4];
 const double = x => x * 2;
 list.map(double);

✔例子:toggle三态切换

三原色红绿蓝的点击切换

image.png image.png image.png

<div id="switcher" class="red"></div>

👉命令式:多个if-else分支

 switcher.addEventListener('click', (evt)=>{
     if(evt.target.className === 'red'){
         evt.target.className = 'green';
     } else if(evt.target.className === 'green'){
         evt.target.className = 'blue';
     } else {
         evt.target.className = 'red';
     }
 });

👉声明式:定义toggle高阶函数,用数组循环地储存action

function toggle(...actions){  // 传入到数组actions
    return function(...args){
        // 删除第一个元素,并返回被删除的元素(取出第一个action)
        let action = actions.shift();
        // 数组末尾加回来(放回)
        actions.push(action);
        // 执行取出的action
        return action.apply(this, args);
    }
}
switcher.addEventListener('click', toggle(
    evt => evt.target.className = 'green',
    evt => evt.target.className = 'blue',
    evt => evt.target.className = 'red'
))