跟着月影学JavaScript|青训营笔记

74 阅读8分钟

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

跟着月影学JavaScript

写好JavaScript的三个原则

  1. 各司其责:HTML,CSS,JavaScript三者分离,各自做自己应该做的事。
  2. 组件封装:一个UI组件应该拥有正确性,可扩展性以及可复用性。
  3. 过程抽象:利用函数闭包,函数式编程的思想。

各司其责

举个例子:深夜食堂

  • 一个网页要求实现浅色深色两种模式。

版本一

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 = '🌞';
    }
});
  • 缺点:由JS直接控制样式,没有使用CSS。

版本二

const btn = document.getElementById('modeBtn');
btn.addEventListener('click', (e) => {
    const body = document.body;
    if(body.className !== 'night') {
        body.className = 'night';
    } else {
        body.className = '';
    }
});
  • 优点:由JS控制元素的class标签最为合适,由CSS设置元素样式。
  • 缺点:JS代码可复用性低,一次性代码。

版本三

<input id="modeCheckBox" type="checkbox">
<div class="content">
    <header>
        <label id="modeBtn" for="modeCheckBox"></label>
        <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;
}

#modeCheckBox:checked + .content {
    background-color: black;
    color: white;
    transition: all 1s;
}
  • 优点:仅使用了HTML和CSS,没有JS代码。

总结

  • HTML,CSS,JS各司其责
  • 避免不必要的JS直接操作样式
  • 可以利用class来表示状态
  • 对于纯展示的页面可以尝试仅使用HTML和CSS实现

组件封装

举个例子:轮播图

  • 使用原生的JS写一个轮播图

  • 结构:HTML

    <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>
    </div>
    

    使用HTML确定页面结构,对于轮播图可以使用无需列表实现。

  • 表现:CSS

    #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;
        transition: opacity 1s;
        opacity: 0;
        text-align: center;
    }
    
    .slider-list__item--selected{
        transition: opacity 1s;
        opacity: 1;
    }
    

    使用CSS的绝对定位将所有的列表项叠在一起,切换动画使用transition。

  • 行为:JS

    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());
        }
        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);  
        }
    }
    
    const slider = new Slider('my-slider');
    slider.slideTo(3);
    
    • 轮播图支持五个动作:
      • getSelectedItem():获取选中的列表项
      • getSelectedItemIndex():获取选中的项的下标
      • slideTo(idx):跳转到某一项
      • slideNext():跳转到下一项
      • slidePrevious():跳转到上一项
  • 控制流:

    <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>
    
    const detail = {index: idx}
    const event = new CustomEvent('slide', {bubbles:true, detail})
    this.container.dispatchEvent(event)
    

    此处控制流使用了自定义事件解耦合

改进方式

  • 插件化:

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

    将slider组件通过依赖注入的方式插入插件

  • 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 => `
            <li class="slider-list__item">
              <img src="${image}">
            </li>    
          `.trim());
    
            return `<ul>${content.join('')}</ul>`;
        }
        ...
    }
    

    将HTML模版化,放入js中进行生成,更易于扩展

  • 抽象:

    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 ''
        }
    }
    

    将组件通用模型抽象出来(组件框架)

总结

  • 组件设计的原则:封装性,正确性,可扩展性,可复用性
  • 实现组件的步骤:结构,表现,行为
  • 优化:
    • 插件化
    • 模版化
    • 抽象化(组件框架)
  • 听完月影老师讲这段组件封装再结合之前学习的react框架恍然大悟理解了react的设计理念。

过程抽象

  • 在实际项目开发过程中,经常会遇到很多需要大量重复或者大量相似的,并且与实际业务没什么直接关联的功能,例如前端请求次数的限制,限制某个方法只执行一次,防抖操作等等。
  • 为了实现这些功能,经常会导致项目中存在大量重复代码,不仅可能会破坏业务逻辑,还大大降低了代码可读性。
  • 过程抽象,将那些需要重复的操作抽象出来,利用函数闭包的语法,实现类似装饰器的效果,将业务代码加上这些功能。

例子

  • 操作次数限制:一次性的点击操作。

    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';
            setTimeout(() => {
                list.removeChild(target.parentNode);
            }, 2000);
        });
    });
    

    点击一个复选框,两秒钟后消除复选框

    这段代码可以实现操作,但是如果用户在还没有消失的时候连续点击多次,就会触发多个click事件,导致在元素已经被删除后继续尝试删除导致报错。

    考虑如何能让这个函数只能执行一次。

高阶函数

  • 高阶函数指一个函数的参数和返回值都是一个函数的函数
  • 向高阶函数中传入一个函数,做一些装饰,再将这个函数返回,通过装饰的行为给函数加上一些功能

HOF0(等价函数)

  • 传入和返回的函数等价

    function HOF0(fn) {
        return function(...args) {
            return fn.apply(this, args);
        }
    }
    

常用高阶函数

Once
  • 修饰传入的函数使其只能执行一次

    function once(fn) {
        return function(...args) {
            if(fn) {
                const ret = fn.apply(this, args);
                fn = null;
                return ret;
            }
        }
    }
    
    const foo = once(() => {
        console.log('bar');
    });
    
    foo();
    foo();
    foo();
    

    可以看到调用了三次经过修饰的foo函数,但是由于第一次执行之后闭包中就将函数设置为了null,因此foo只会执行一次

    image-20220726151614219
Throttle
  • 节流函数,修饰传入的函数使得其每过一定时间只能执行一次

    function throttle(fn, time = 500){
        let timer;
        return function(...args){
            if(timer == null){
                fn.apply(this,  args);
                timer = setTimeout(() => {
                    timer = null;
                }, time)
            }
        }
    }
    
    btn.onclick = throttle(function(e){
        circle.innerHTML = parseInt(circle.innerHTML) + 1;
        circle.className = 'fade';
        setTimeout(() => circle.className = '', 250);
    });
    

    第一次调用throttle返回的函数时,timer为null,执行一次函数然后设置一个延迟,时间到后再把timer设置为null,也就是说在time的时间之内再次点击按钮,由于timer不为null,因此不会执行函数,这样就实现每time毫秒只能点击一次的操作。

Debounced
  • 防抖函数,在鼠标不停活动的时候不调用函数,当鼠标活动后停下来一段时间后如果没有移动才触发函数

    var i = 0;
    setInterval(function(){
        bird.className = "sprite " + 'bird' + ((i++) % 3);
    }, 1000/10);
    
    function debounce(fn, dur){
        dur = dur || 100;
        var timer;
        return function(){
            clearTimeout(timer);
            timer = setTimeout(() => {
                fn.apply(this, arguments);
            }, dur);
        }
    }
    
    document.addEventListener('mousemove', debounce(function(evt){
        var x = evt.clientX,
            y = evt.clientY,
            x0 = bird.offsetLeft,
            y0 = bird.offsetTop;
    
        console.log(x, y);
    
        var a1 = new Animator(1000, function(ep){
            bird.style.top = y0 + ep * (y - y0) + 'px';
            bird.style.left = x0 + ep * (x - x0) + 'px';
        }, p => p * p);
    
        a1.animate();
    }, 100));
    

    在debounce函数中定义了一个timer,每次调用debounce函数返回的函数时,会清除定时器之后设置一个延时执行,这样如果在延时还没有结束的时候又移动鼠标(触发函数)就会清除定时器,导致上次移动操作没有被执行,直到某次鼠标移动停止并且在dur时间内没有再次移动,小鸟才会移动位置。

    image-20220726152730706
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)
            }
        }
    }
    

    当第一次调用函数时,会把函数push到一个数组(队列)中,接着由于是第一次执行,timer为null,因此创建一个定时任务,每隔time时间取出队列一个任务进行执行,如果在此期间再次调用此函数,则会将任务加入队列,又由于timer不是null,就会在队列内等待执行。当队列所有任务都执行结束之后,timer会被置为null,这样下一次再次调用的时候就可以重新创建一个定时任务。

编程范式

  • 命令式以及声明式

  • 命令式

    let list = [1, 2, 3, 4];
    let mapl = [];
    for(let i = 0; i < list.length; i++) {
        mapl.push(list[i] * 2);
    }
    
  • 声明式

    let list = [1, 2, 3, 4];
    const double = x => x * 2;
    list.map(double);
    
  • 命令式更加看重问题是如何一步一步执行的,而声明式更看重语意的表达

  • 在JavaScript中,允许使用声明式和命令式两种写法,一般推荐使用声明式写法

举个例子

  • Toggle(开关)

命令式

switcher.onclick = function(evt){
    if(evt.target.className === 'on'){
        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 = 'off',
    evt => evt.target.className = 'on'
);
  • 命令式更注重业务是如何实现的,而声明式定义了一个列表,不断循环切换这个列表的状态
  • 声明式的优点:当前这个只实现了两个状态开和关的切换,而如果需要更多状态的切换,就只需要在调用toggle的时候添加项就可以了,而命令式如果想添加状态只能添加elseif,相对而言声明式更简洁。

总结

  • 经过半节课的JavaScript的学习,了解到如何写好JavaScript,JS的编写理念以及不同编程范式的使用。
  • 通过轮播图的案例,学习到插件的使用方法,发现了自己以前写轮播图方式的不足以及不可扩展性。