如何写好 JavaScript | 青训营笔记

74 阅读5分钟

这是我参与「第四届青训营 」笔记创作活动的的第4天,整理一下昨天月影老师在课上讲的关于“如何写好 JavaScript”的相关内容。

一、本节课重点内容

写好 JS 的三大原则:

  1. 各司其责
  2. 组件封装,使用插件化、模板化、抽象三种方法对组件进行重构
  3. 过程抽象

二、详细知识点介绍

1. 写好 JS 的一些原则

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

2. 原则1:各司其责

以点击按钮实现页面在“日间模式”和“夜间模式”之间切换为例

  • 方案一:通过 JS 修改样式

  • 方案二:通过 CSS 修改样式,而 JS 只修改需要应用样式的元素的类名

  • 方案三:完全通过 CSS 实现(需要添加一个隐藏的 checkbox)

  1. 应当避免直接用 JS 操作样式
  2. 可以用class来表示状态,样式由 CSS 根据控制,状态由 JS 控制
  3. 纯展示类交互应寻求零 JS 方案

3. 原则2:组件封装

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

以轮播图组件为例

  • 对基础行为进行封装:获取当前项、切换到指定项、切换到下一项、切换到上一项

省略部分具体实现

class Slider {
    constructor(id) {
        this.container = document.getElementById(id);
        this.items = this.container.querySelectorAll(".slider-list__item");
    }
    
    getSelectedItem() { // 获取当前项
        return this.container.querySelector(".slider-list__item[selected]");
    }
    
    getSelectedItemIndex() { // 获取当前项的索引
        return Array.from(this.items).indexOf(this.getSelectedItem());
    }
    
    slideTo(index); // 切换到指定项
    
    slideToNext() { // 切换到下一项
        const currentIndex = this.getSelectedItemIndex();
        const nextIndex = (currentImdex + 1) % this.items.length;
        this.slideTo(nextIdx);
    }
    
    slideToPrevious() { // 切换到上一项
        const currentIndex = this.getSelectedItemIndex();
        const previousIndex = (this.items.length + currentIndex - 1) % this.items.length;
        this.slideTo(previousIndex);
    }
}
  • 通过事件监听为轮播图组件添加控制功能
class ControllableSlider extends Slider {
    constructor(id) {
        super.constructor(id);
        
        const controller = this.container.querySelector(".slide-list__control");
        if (controller) {
            const buttons = controller.querySelectorAll(".slide-list__control-buttons");
            // 鼠标悬停时切换到指定项并停止播放
            controller.addEventListener("mouseover", e => {
                const index = Array.from(buttons).indexOf(e.target);
                if (index >= 0) {
                    this.slideTo(index);
                    this.stop();
                }
            });
            
            // 鼠标移出时继续播放
            controller.addEventListener("mouseout", e => {
                this.start();
            });
      
            // 监听自定义的 slide 事件,实现控制器与轮播图保持一致
            this.container.addEventListener("slide", e => {
                const index = e.detail.index
                const selected = controller.querySelector(".slide-list__control-buttons[selected]");
                if(selected) selected.className = "slide-list__control-buttons";
                buttons[index].className = "slide-list__control-buttons--selected";
            })
        }
    
        const previous = this.container.querySelector(".slide-list__previous");
        if (previous) {
            // 点击切换上一项的按钮
            previous.addEventListener("click", e => {
                this.stop();
                this.slidePrevious();
                this.start();
                e.preventDefault();
            });
        }

        const next = this.container.querySelector(".slide-list__next");
        if (next) {
            // 点击切换下一张的按钮
            next.addEventListener("click", e => {
                this.stop();
                this.slideNext();
                this.start();
                e.preventDefault();
            });
        }
    }
    
    slideTo(index) {
        super.slideTo(index);
        
        // 派发自定义的 slide 事件
        const detail = {index}
        const event = new CustomEvent('slide', {bubbles: true, detail})
        this.container.dispatchEvent(event)
    }
}

这种方法的问题在于轮播图组件和控制器组件之间的耦合程度太高,从而导致其构造函数过于复杂

(1)重构方法1:插件化

  • 将控制元素抽取成插件
  • 插件与组件之间通过依赖注入方式建立联系
class PluginSlider extends Slider {
    registerPlugins(...plugins){
        plugins.forEach(plugin => plugin(this));
    }
}

function pluginController(slider) {
    // 同上一个方案中构造函数的相关部分
}

function pluginPrevious(slider) {
  // 同上一个方案中构造函数的相关部分
}

function pluginNext(slider) {
  // 同上一个方案中构造函数的相关部分
}

const slider = new Slider("my-slider");
// 注册插件
slider.registerPlugins(pluginController, pluginPrevious, pluginNext);
slider.start();

此方法无法实现这几部分中 HTML 相关内容的解耦

(2)重构方法2:模板化

render() {
    const content = this.images.map(image => `
      <li class="slider-list__item">
        <img src="${image}"/>
      </li>    
    `.trim());
    return `<ul>${content.join('')}</ul>`;
}
this.container.innerHTML = this.render();
// 控制器插件同理,在这里不再重复

通过 JS 生成轮播图及控制器插件的 HTML 模板

(3)重构方法3:抽象

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 { /* 具体实现方法省略 */ };

将组件的公共部分进行抽离,抽象出一个组件框架

4. 原则3:过程抽象

过程抽象是函数式编程思想的基础应用,其只关心函数的输入和输出而不关心函数的具体实现 常用高阶函数

  • Once:目标函数只允许执行一次
function once(fn) {
  return function(...args) {
    if(fn) {
      const ret = fn.apply(this, args);
      fn = null;
      return ret;
    }
  }
}
  • Throttle:截流,固定时间间隔内只执行一次
function throttle(fn, time = 500){
  let timer;
  return function(...args){
    if(timer == null){
      fn.apply(this,  args);
      timer = setTimeout(() => {
        timer = null;
      }, time)
    }
  }
}
  • Debounce:防抖,固定时间间隔内的连续操作只执行最后一次
function debounce(fn, dur){
  dur = dur || 100;
  var timer;
  return function(){
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, arguments);
    }, dur);
  }
}
  • Consumer:实现异步方法的排队调用
function consumer(fn, time){
  let tasks = [], timer;
  return function(...args){
    tasks.push(fn.bind(this, ...args));
    if(timer == null){
      timer = setInterval(() => {
        tasks.shift().call(this)
        if(tasks.length <= 0){
          clearInterval(timer);
          timer = null;
        }
      }, time)
    }
  }
}
  • Iterative:对可迭代对象中的所有元素执行相同的操作
function iterative(fn) {
  return function(subject, ...rest) {
    if(isIterable(subject)) {
      const ret = [];
      for(let obj of subject) {
        ret.push(fn.apply(this, [obj, ...rest]));
      }
      return ret;
    }
    return fn.apply(this, [subject, ...rest]);
  }
}

为什么要使用高阶函数:

高阶函数将目标函数变成一个纯函数,即函数的执行结果只取决于传入函数的参数而与外界无关;

同时其可以消除函数执行带来的副作用,即不会对外界产生影响

三、课后个人总结

在这节课中令我受益匪浅的主要包含两部分的内容:

  • 组件化的思想:虽然在平时写代码的过程中我也会有对可复用的组件进行抽象和封装的习惯,但是月影老师上课所讲的另外两种重构方法——插件化和模板化确实让我收获很多,这两种方法可以实现细小模块之间的解耦,从而提高组件的封装性、可扩展性和可复用性;
  • 函数式编程思想:这一部分老师主要讲解的是高阶函数的应用,我还是非常喜欢这种编程思想的,之前写 Python 的时候就很喜欢用装饰器的方式去对函数进行扩展(等有空了我要再继续补充当时写的工具库),两者从本质思想上是相同的。

四、引用参考