做前端这么多年,你真的懂状态驱动吗

lxf2023-05-05 10:39:01

用状态驱动来思考

一生二, 二生三, 三生万物

道家有句话这样子说:一生二, 二生三, 三生万物。

如果在开发的过程中:如果你认可这句话中的一指状态的话, 那么你应该就是在用状态驱动开发程序了。

接到开发任务后,我们会对业务进行分析, 然后建立业务模型, 再然后编写代码实现相关的逻辑。

可以把视业务模型为状态, 则后面编写的状态修改、展示逻辑等为二,为三,为万物。

万事围绕状态来开展。

尝试重新认识 '='

在刚学习编程的时候, 老师曾经强调过这个运算符是赋值的意思, 不同于数学中的 '='。 而在状态驱动中, 这个 '=' 大部分情况下应该被认作定义某种关系, 或者说,通过某种方式获得;

对于展示信息的逻辑可以看做将状态经过某种规则运算之后的结果展示出来。

对于方法来说: 按照某种规则来修改状态。

整个设计思路应该是在声明某种规则,某种与状态有关的规则。

对于声明, 目前为止我觉得最贴切的一句话是:上帝说要有光, 然后便有了光。

万物皆生于一, 一变则万物知

说万物变有点夸张。 当我们的程序均围绕状态来展开, 即万物均与一产生了联系,再加上现代框架的响应式,一变则万物知还是可以的。

小结

所以我们开发时可以按照以下流程来:建立业务模型(抽象出状态, 声明修改规则),开发界面, 绑定关系。

简单封装一个Swiper组件作为示例

提炼基础状态

一般对于一个Swiper组件来说, 他的作用是展示一个列表中某一项的内容。

所以他应该有一个列表, 然后有一个指针指向当前展示的内容所在。

class Swiper{
    list: any[] = [];
    current: number = 0;
}

定义修改相关逻辑

翻页

围绕 current 展开的一系列修改规则

上下翻页 或者 指定翻到某一页

class Swiper {
    list: any[] = [];
    current: number = 0;
    next(){
        const {length} = this.list;
        const next = this.current + 1;
        this.current = next >= length ? length - 1 : next;
    }
    prev(){
        const next = this.current - 1;
        this.current = next < 0 ? 0 : next;
    }
    setCurrent(current: number){
        if (current < 0 || current >= this.list.length) return;
        this.current = current;
    }
}

对于上下翻页的临界时的逻辑: loop

在翻页到达边界的时候, 发现逻辑缺失。添加 loop 状态来声明该场景下的规则。

class Swiper {
    loop: boolean = true;
    list: any[] = [];
    current: number = 0;
    next(){
        const {length} = this.list;
        const next = this.current + 1;
        this.current = this.loop ? (next % lenght) : next >= length ? length - 1 : next;
    }
    prev(){
        const {length} = this.list;
        const next = this.current - 1;
        this.current = this.loop ? (next + length) % length : next < 0 ? 0 : next;
    }
    setCurrent(current: number){
        if (current < 0 || current >= this.list.length) return;
        this.current = current;
    }
}

自动翻页

每隔一段时间 current + 1;

那么自动轮播的核心状态:间隔时间

class Swiper{
    duration: number = 3000;
}

考虑轮播的触发模式

  1. requestAnimationFrame
  2. setTimeout
  3. setInterval

requestAnimationFrame方案: 记录上次触发的时间, 每次屏幕刷新的时候比较时间差,溢出则触发;较为耗费性能,但精度相对较为准确

setInterval: 每隔固定时间建立触发任务, 无论上次任务是否执行, 不太推荐

setTimeout:在上次任务执行完后重新建立任务;

这里选择 setTimeout 进行实现。

class Autoplay{
    timer?: number = undefined;
    duration: number = 3000;
    constructor(public target: Swiper)
    stop(){
        this.timer && clearTimeout(this.timer);
    }
    timeout(){
        this.timer = setTimeout(() => {
            // dosomeThing;
            this.timeout();
        }, this.duration);
    }
    start(){
        this.stop();
        this.timeout();
    }
}

将 dosomeThing 抽离出来,

class Swiper{
    // ...
    duration: number = 3000;
    autoplayHandler(){
        const next = (this.current + 1) % this.list.length;
        this.setCurrent(next);
    }
}

class Autoplay{
    timer?: number = undefined;
    constructor(public target: Swiper){}
    stop(){
        this.timer && clearTimeout(this.timer);
    }
    timeout(){
        this.timer = setTimeout(() => {
            this.target?.autoplayHandler?.();
            this.timeout();
        }, this.target.duration);
    }
    start(){
        this.stop();
        this.timeout();
    }
}

加上AutoPaly的Swiper

class Swiper{
    // ...
    autoplay: boolean = true;
    autoplayUtils = new AutoPlay(this);
    autoplayHandler(){
        const next = (this.current + 1) % this.list.length;
        this.setCurrent(next);
    }
    watchAutoPlay(){
        this.autoplayUtils[this.autoplay ? 'start' : 'stop']?.()
    }
}

通过手势触发

移动端的需求一般都是通过触摸划屏来实现的。 比如左滑上一项, 右划下一项。

那么我们需要知道 开始触点, 和结束触点, 来比较两个触点划过的距离和方向来确定是否触发和触发的方向。

class Drag{
    startPoint: {x: number, y: number} = {x: 0, y: 0};
    endPoint: {x: number, y: number} = {x: 0, y: 0};
    
    getOffset(){
        return {
            x: this.endPoint.x - this.startPoint.y,
            y: this.endPoint.y - this.startPoint.y,
        }
    }
}

仅仅获取移动过得距离来确定是否触发, 体验不是很好。 比如滑动的很快, 但是距离短。 按照预期, 应该也是被触发了的。

这里简单的以最后两次触发move事件的点来计算速度。

class Drag{
    startPoint: {x: number, y: number} = {x: 0, y: 0};
    prevPoint: {x: number, y: number} = {x: 0, y: 0};
    endPoint: {x: number, y: number} = {x: 0, y: 0};
    
    getDelta(){
        return {
            x: this.endPoint.x - this.startPoint.y,
            y: this.endPoint.y - this.startPoint.y,
        }
    }
    getSpeed(){
        return {
            x: this.endPoint.x - this.prevPoint.y,
            y: this.endPoint.y - this.prevPoint.y,
        }
    }
}

加上各触点的修改逻辑

interface IPosition{
    x: number;
    y: number
}
class Drag{
    touching: boolean = false; // 其他地方可能用的上
    startPoint: IPosition = {x: 0, y: 0};
    prevPoint: IPosition = {x: 0, y: 0};
    endPoint: IPosition = {x: 0, y: 0};
    
    touchstart(position: IPosition){
        this.startPoint = this.prevPoint = this.endPoint = position;
    }
    touchmove(position: IPosition){
        this.touching = true;
        this.prevPoint = this.endPoint;
        this.endPoint = position;
    }
    touchend(position: IPosition){
        this.touching = false;
    }
    getDelta(): IPosition{
        return {
            x: this.endPoint.x - this.startPoint.y,
            y: this.endPoint.y - this.startPoint.y,
        }
    }
    getSpeed(): IPosition{
        return {
            x: this.endPoint.x - this.prevPoint.y,
            y: this.endPoint.y - this.prevPoint.y,
        }
    }
}

判断是否应该触发: (speedRate: 速度系数)

class SwiperDrag extends Drag {
    constructor(public onTrigger: (delta: IPosition) => void, public speedRate: number = 10){}
    getOffset(){
        const delta = this.getDelta();
        const spped = this.getSpeed();
        return {
            x: delta.x + spped.x * this.speedRate,
            y: delta.y + spped.y * this.speedRate,
        }
    }
    trigger(){
        this.onTrigger?.(this.getOffset());
    }
}

加上SwiperDrag的Swiper

class Swiper1 extends Swiper{
    drag: new SwiperDrag((delta: IPosition) => {
        // 左右滑动
        if (delta.x > 300) this.next();
        if (delta.x < -300) this.prev();
        
        // 上下滑动
        // if (delta.y > 300) this.next();
        // if (delta.y < -300) this.prev();
    })
}
// 自行绑定事件。

关于内容的修改

关于 list 这块,这块的需求不多,就不介绍了; 比如 list整个换了, current归0,删除某一项如何处理等。

绘制界面

不同的需求对这块的样式需求是不一样的。这里就不详细展开讲了。

绑定关系

<div class='swiper-wrap'>
    <div class="content" :style="wrapStyle">
        <div class="swiper-item" v-for="(item, index) in swiper.list">
            <img :src="item.imageUrl"/>
        </div>
    </div>
    <div
        class="swiper-icons"
        v-for="(item, index) in swiper.list"
        @click="swiper.setCurrent(index)"
    />
    <div class="btn-prev" @click="swiper.prev()"/>
    <div class="btn-next" @click="swiper.next()"/>
</div>
export default{
    data(){
        return {
            swiper: new Swiper1()
        }
    },
    computed: {
        // 这里演示的是 将父容器的x位移来显示不同的内容
        wrapStyle(){
            const offsetX = this.swiper.drag.touching ? this.swiper.drag.getOffset().x : 0;
            return {
                transform: `translateX(${-100 * this.swiper.current}% + ${offsetX}px)`
            }
        }
    },
    methods: {
        // 这里演示的是:为单独的元素设置不同的 class, 不同的class 显示不同的样式来实现
        itemClass(index, current){
            return {
                current: index === current,
                next: index === (current + 1) % this.swiper.list.length,
                prev: index === (current - 1 + .swiper.list.length) % this.swiper.list.length,
            }
        },
    },
    watch: {
        'autoplay.autoplay': {
            immediate: true,
            handler(){
                this.swiper.watchAutoPlay();
            }
        },
    },
    beforeDestory(){
        this.swiper.autoplayUtils.stop();
    }
}

写在后面

感谢看到这里的各位, 最后问一个问题:下面的写法算不算状态驱动。

<div id="app"></div>

<script>
let text = '';
const app = document.querySelector('#app')
function update(){app.innerHTML = text;}
update();
app.addEventListener('click', function(){
    text = text ? '' : `state-driven`
    update();
});
</script>