如何写好JavaScript | 青训营笔记

51 阅读10分钟

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

一、本堂课主要内容

  1. JavaScript 好代码的标准
  2. HTML/CSS/JS 各司其责
  3. 组件的定义解析及特征
  4. 组件封装基本方法
  5. 过程抽象概念
  6. 高阶函数使用模式
  7. JavaScript 编程范式
  8. 代码写作关注事项
  9. 代码实践

二、知识点介绍

本节课讲的是怎么写好JavaScript,因此一些语法、api之类的就不讲了,可以自己去学学,如JS基础语法、es6语法、DOM、BOM、Promise、async/await、原型链、闭包、事件循环等。

1. 各司其责

html、css、js各干各的活。html控制页面的内容结构,css控制样式、js控制页面行为,各司其责,这样代码结构清晰,便于阅读和维护。例:

切换页面黑白主题

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>深夜食堂</title>
    <style>
        body, html {
            width: 100%;
            height: 100%;
            padding: 0;
            margin: 0;
        }
        body {
            padding: 10px;
            box-sizing: border-box;
        }
        img {
            width: 100%;
        }
        #modeBtn {
            font-size: 2rem;
            float: right;
            border: none;
            background: transparent;
        }
    </style>
</head>
<body>
    <header>
        <button id="modeBtn">🌞</button>
        <h1>深夜食堂</h1>
    </header>
    <main>
        <img src="https://p2.ssl.qhimg.com/t0120cc20854dc91c1e.jpg">
        <div class="description">
            <p>
                这是一间营业时间从午夜十二点到早上七点的特殊食堂。这里的老板,不太爱说话,却总叫人吃得热泪盈
                眶。在这里,自卑的舞蹈演员偶遇隐退多年舞界前辈,前辈不惜讲述自己不堪回首的经历不断鼓舞年轻人,最终令其重拾自信;轻言绝交的闺蜜因为吃到共同喜爱的美食,回忆起从前的友谊,重归于好;乐观的绝症患者遇到同命相连的女孩,两人相爱并相互给予力量,陪伴彼此完美地走过了最后一程;一味追求事业成功的白领,在这里结交了真正暖心的朋友,发现真情比成功更有意义。食物、故事、真情,汇聚了整部剧的主题,教会人们坦然面对得失,对生活充满期许和热情。每一个故事背后都饱含深情,情节跌宕起伏,令人流连忘返
                [6] 。
            </p>
        </div>
    </main>
    <script>
        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 = '🌞';
            }
        });
    </script>
</body>
</html>

这里就是点击切换主题按钮后,js直接修改样式。我们做些改变,看看下面的代码

body { ... }
body.night { ... }
#modeBtn::after {
  content: '🌞';
}
body.night #modeBtn::after {
  content: '🌜';
}
const btn = document.getElementById('modeBtn');
btn.addEventListener('click', (e) => {
  const body = document.body;
  if(body.className !== 'night') {
    body.className = 'night';
  } else {
    body.className = '';
  }
});

这里改的不再是样式而是类名,然后不同主题的样式写在不同的类名里面。很明显,下面一种方法更优。

  • 第二种方法改的是类名而不是样式,这样写css样式时有代码提示(very good)
  • 第二种写法也能方便的修改样式,直接找到css对应位置轻松修改,而不是像第一种方法一样找到js中对应位置,再苦闷的修改,甚至项目复杂时,还可能js这边改一点还不够,得css那边也改一点,这样代码的可维护性就很差。
  • 第二种写法也更容易改变不同主题的子元素的样式。比如黑夜模式下按钮要变成金色,第二种小手一挥就搞定,第一种又要写不知多少代码,复杂又臃肿
  • 要添加多个主题时,也明显第二种层次更清晰

再来看一种无js写法

#modeCheckBox {
  display: none;
}
#modeCheckBox:checked + .content {
  background-color: black;
  color: white;
}
<input id="modeCheckBox" type="checkbox">
<div class="content">
    <header>
      <label id="modeBtn" for="modeCheckBox"></label>
      <h1>深夜食堂</h1>
    </header>
    <main>
      ...
    </main>
</div>

输入框隐藏,不会影响到页面布局,主题切换按钮(label)绑定输入框,点击label,输入框就会切换勾选状态,勾选时css样式就触发,主题就切换了。

这种方法也显得优雅,而且方便修改主题样式,多个label多个单选框也能弄多套主题。当然,这并不意味着第二种方法就不行了,其实第二种方法也足够简洁,而且写法也简单,层次分明,是大多数时候的选择方案,html、css、js配合起来使用很正常,不必刻意寻求零js解决方案。

2. 组件封装

这里展示一个轮播图案例,主要部分代码如下:

<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>
class Slider {
    // 元素id,循环时间
    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');
            // 给下面N个点控制器添加事件监听,鼠标移上那个点就展示第几张图片
            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 => {
                // 获得穿过来的参数
                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();

效果如下 image.png

样式部分代码就不展示了,html部分为结构,4张图片,4个点,2个左右滑动,js就控制滑动、自动滑动等。

看起来代码还不错,但是耦合性太高了,不够灵活,比如要是要去掉下面控件、去掉左右2个控件,又得这改改那改改,要是是给别人用的,做成一个公共的,明显不行。

插件化思想

所有的控件都弄成插件,想要就添加,不要可不添加

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));
    }
    ...
}

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

Slider.registerPlugins提供插件,函数pluginControllerpluginPreviouspluginNext则是注册3个插件,底下和左右的控件,这样想要什么控件,添加就是。

但是还不行,比如我要添加一个插件,就得html写上元素,css写好样式,再js注册插件,做些改进:

  • 注册插件时不用获取元素,而是创建新的元素,这样就不用修改html对应部分了
  • 图片部分也移到js,提供一个图片url数组,然后循环创建标签

代码如下:

<div id="my-slider" class="slider-list"></div>
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 => `
            <li class="slider-list__item">
            <img src="${image}"/>
            </li>    
        `.trim());

        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 pluginController = {
    render(images) {
        return `
        <div class="slide-list__control">
          ${images.map((image, i) => `
              <span class="slide-list__control-buttons${i === 0 ? '--selected' : ''}"></span>
           `).join('')}
        </div>    
      `.trim();
    },
    action(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';
            });
        }
    }
};


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(pluginController);
slider.start();

这样就能很轻松的控制轮播图的各个部分了。当然,可能会有人说这不是与上面的各司其责相悖吗?其实不的,各司其责是各干各的活,而不是各找各的位置,这里面依旧是html控制结构、css控制样式、js控制行为的。

此外,还可以将通用的部分抽象成框架,像 这样

最后,记住组件的设计原则:封装性、正确性、扩展性、复用性。然后,其实上述代码还是有一些可以改进的地方的,比如获取图片元素时用的固定类名,获取SelectedItem的函数等等。

3. 过程抽象

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

函数式编程就是重在逻辑结果,比如下面两段函数:

let arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

function solution1(arr) {
    let result = 0
    for(let i = 0; i < arr.length; i++) {
        if(i % 2 === 0) {
            result += i * i
        }
    }
    return result
}

function solution2(arr) {
    return arr
        .filter(i => i % 2 === 0)
        .map(i => i * i)
        .reduce((pre, cur) => pre + cur, 0)
}

solution2就是函数式编程,可以很清楚的看到都做了些什么,先是找出2的倍数,再把这些数平方,再相加。而solution1,命令式编程,就不易于看懂做了些什么(这个例子简单,所以其实容易看懂也很正常)。

函数式编程就性能上肯定是比不过命令式编程的,毕竟就这个例子来说,函数式编程调用了3次函数,还创建了新的数组,开销肯定大于命令式编程的。但是,现代计算机不差这点性能,除非你跑大型数据之类的,就我们平时的应用场景来说,使用函数式编程带来的性能额外开销是不足考虑的,而且使用函数式编程明显逻辑、流程更清晰。

再回到过程抽象,其实就是把一些可重复的过程提取成方法,比如多次调用函数只执行一次、节流、防抖,把这些过程提取出来然后就可以多次使用,而不需要每次使用时都得写一遍

// 只执行一次
function once(fn) {
    return function (...params) {
        if(fn) {
            let res = fn.apply(this, params)
            fn = null
            return res
        }
    }
}
const log = once(() => console.log(1))
log()
log()
log()

// 节流,连续多次调用时,最多time(ms)执行一次
function throttle(fn, time = 500) {
    let timer = null
    return function(...params) {
        if(timer) return
        fn.apply(this, params)
        timer = setTimeout(() => timer = null, time)
    }
}

// 防抖,连续多次调用时(调用间隔在time(ms)内),只执行最后一个
function debounce(fn, time = 500) {
    let timer = null
    return function(...params) {
        clearTimeout(timer)
        timer = setTimeout(() => {
            fn.apply(this, params)
        }, time)
    }
}

三、注意点

写代码时要关注风格、效率、约定、使用场景、设计。

这里要注意使用场景,比如你写一个排序算法,但只会用在小数据的排序上,就几个、十几个元素的排序,这时其实O(n^2)和O(nlogn)的区别就不大了,这时就不必揪着不放的想着怎么优化你的排序算法(只是说不必揪着不放,不是完全不需要优化)。再比如上面的轮播图,如果只用在你自己项目里,就用那一个地方,那其实第一种写法也无妨,最多就弄成数据驱动的,可以适应更多或更少的图片。

再揭示一个误区,就是打乱数组元素顺序时,可能很多人是这么写的

arr.sort(() => Math.random() - 0.5)

其实这个不够随机,因为sort函数内部对不同元素比对次数的问题,原本就在前面的元素更容易还是排在前面,这是因为小数据量时采用插入排序,后面的元素想排到前面要比对更多次数,而每次比对只有50%的概率往前移动。如下可以测试一下:

let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9]

function randomSort(arr) {
    return arr.sort(() => Math.random() - 0.5)
}

let res = Array(9).fill(0)
for (let i = 0; i < 1000000; i++) {
    let temp = randomSort([...arr])
    for(let i = 0; i < temp.length; i++) {
        res[i] += temp[i]
    }
}

console.table(res)
// ┌─────────┬─────────┐
// │ (index) │ Values  │
// ├─────────┼─────────┤
// │    0    │ 4519877 │
// │    1    │ 4519880 │
// │    2    │ 4975707 │
// │    3    │ 5052507 │
// │    4    │ 5058296 │
// │    5    │ 5040230 │
// │    6    │ 5068165 │
// │    7    │ 5211657 │
// │    8    │ 5553681 │
// └─────────┴─────────┘

可以改一下,思路这样:从1 - n个位置随机选一个,把这个元素移到最后,再1 - n-1个位置随机选一个,也移到最后,如此下去。每个数被选中的概率都一样,第一个为1/n1/n,第二个为 (n1)/n1/(n1)(n-1)/n * 1/(n-1),后面的也一样,都是1/n,不信的可以像上面一样去测试测试。

四、 个人总结

写JavaScript还是要注意很多原则和思路的,要多学习掌握,掌握各种方法,写出优秀的代码。