阅读 522

【框架 · 序】为什么需要亲手设计一个前端框架

如何看待自制框架这件事情

在前端的远古时代,前端的框架就有很多了,如 knockout、backone、extjs …… 都曾由众多使用者。然而由于那时候还是以 jsp php asp 等后端渲染架构为主流,其实绝大多数应用还是以 jquery + 控件库的形式进行开发。那时候,相对与框架开发,前端从业者更热衷于挑战用 js + css 在当时的浏览器这种处处受限的平台,实现各种酷炫的动画效果。

几年前,ajax 的广泛应用变成了前端生态变化的钥匙,自此次世代的框架战争就拉起了序幕。各种各样的响应式框架层出不穷。最终的结果,三大框架鼎足的局面已被大家所熟知。

三大框架现已深深植根于各个现在各个前端的心窝里。如今,一个前端不知道三大框架的原理都不好意思上论坛跟人打招呼。与此呼应的是,假使一个人说自己做了一个前端框架,立马会迎来别人的灵魂三问 —— “你做的能由三大框架好吗” “你做的这个有生态吗” “谁会用你的框架呢”,最终则会收到“重复造轮子”的评价,而他的框架则“仅仅是个玩具,没有什么意义”。

为什么需要亲手设计一个前端框架

对于大多数人而言,所谓编程无非仅是写段程序让机器跑罢了。可机器运行只需要指令,为什么程序员写代码需要框架呢?答曰“用框架效率高”。反问之“为什么用框架写的开发效率高?”“代码量少”,紧接着就是些“响应式”“函数式”等等的概念名词了。如果以此角度去理解框架,确乎没有自制框架的必要了。业务开发不如现有成熟的框架效率高,也没有各种云里雾里的概率,这个框架便失去的意义。

机器擅长执行一条接着一条的指令,人却不擅长这个,想必大多数程序员都经历过跟断点的痛苦吧。人擅长通过抽象的方式解决复杂问题,而机器却不会这个。代码是需要人写,却由机器执行。如何写代码就成了一件矛盾的事情。如今形形色色的操作系统,编程语言,设计模式,编程范式,应用框架就是这个问题的答案。

如果将编程看作用合适的方式描述机器指令,那么框架就是这些指令的抽象方式。同样是渲染视图,我们可以将其视为多个组件渲染函数复合而成的渲染函数,我们也可以将其视为由多个互相通信的组件对象组合而成视图对象。不同的应用场景,不同的开发习惯,不同的理解思维都会影响我们的抽象方式。在三大框架之外,我们亦可以尝试去验证自己的抽象方式,这时自制框架就有了意义。

以设计的角度讨论三大框架的统一与分化

从一个简单的数据结构谈起。


export interface Watcher<T> {
    emit: (r: Reactive<T>) => void
}


export class Reactive<T> {
    #val: T
    #watchers: Watcher<T>[] = []

    constructor(val: T) {
        this.#val = val
    } 

    getVal() {
        return this.#val
    }

    setVal(newVal: T) {
        this.#val = newVal
        this.#watchers.forEach(v => v.emit(this))
    }
    
    attach(watcher: Watcher<T>) {
        this.#watchers = this.#watchers.filter(v => v != watcher).concat([watcher])
    }
}

复制代码

这是一个简单的响应式数据Reactive<T>, 里面存着当前的真实数据。当 setVal 改变值的时候会通知所有的 watcher<T> 更新数据。

如果将这个值绑定到视图,可以用代码表述如下

// <div>{{text}}</div>
class View implements Watcher<string>{

    div = document.createElement('div')

    constructor(text: Reactive<string>) {
        text.detach(this)
        this.emit(text)
    }
    emit(v: Reactive<string>) {
        this.div.innerText = v.getVal()
    }
}
复制代码

仅仅是简单地绑定一个字符串,并没有什么难度,可是当绑定的值是一个对象地时候,就有了变化。

<div>
    <div>{{val.id}}</div>
    <div>{{val.name}}</div>
</div>
复制代码

最傻瓜的方式就是把整个 val 看作成一个 Reactive 对象

// <div>
//     <div>{{val.id}}</div>
//     <div>{{val.name}}</div>
// </div>
class View implements Watcher<{id:string,name:string}>{

    div = document.createElement('div')

    constructor(text: Reactive<{id:string,name:string}>) {
        text.detach(this)
        this.emit(text)
    }
    emit(v: Reactive<{id:string,name:string}>) {
        this.div.innerHTML = `
            <div>${v.getVal().id}</div>
            <div>${v.getVal().name}</div>
        `
    }
}
复制代码

还有一种方式就忒暴力了,把对象的每一个属性都拆成 Reactive

// <div>{{text}}</div>
class Div implements Watcher<string>{

    div = document.createElement('div')

    constructor(text: Reactive<string>) {
        text.detach(this)
        this.emit(text)
    }
    emit(v: Reactive<string>) {
        this.div.innerText = v.getVal()
    }

}

// <div>
//     <div>{{val.id}}</div>
//     <div>{{val.name}}</div>
// </div>
class View implements Watcher<{id:Reactive<string>,name:Reactive<string>}>{

    div = document.createElement('div')

    constructor(text: Reactive<{
        id:Reactive<string>,
        name:Reactive<string> }>) {
        text.detach(this)
        this.emit(text)
    }
    emit(v: Reactive<{id:Reactive<string>,name:Reactive<string>}>) {
        this.div.innerHTML = ''
        const div1 = new Div(v.getVal().id)
        const div2 = new Div(v.getVal().name)
        this.div.appendChild(div1.div)
        this.div.appendChild(div2.div)
    }
}
复制代码

前者渲染简单,但是数据变更却要刷新整个视图。

后者虽能在数据变化的时候精准地更新局部视图,但是以这种递归的方式将对象的所有属性转换为 Reactive,在监听对象复杂地情况,难以避免地会带来极大的内存占用。

这两句是不是感觉很眼熟啊。这不就说的是 react 和 vue 吗?

那除去这两种走极端的方法外,是否存在别的方法呢?

export class Computed<S , T>
    extends Reactive<T>
    implements Watcher<S>{

    #fn: (s: S) => T

    constructor(fn: (s: S) => T, source: Reactive<S>) {
        const val = fn(source.getVal());
        super(val);
        this.#fn = fn
    }

    emit(source:Reactive<S>) {
        const val = this.#fn(source.getVal());
        if(val!==this.getVal())  this.setVal(val)
    }

}
复制代码

Computed<Source,Target> 是一个类似于 vue 的计算属性的数据结构。

其根据一个 Reactive<Source> 和一个生成函数 fn: (s: S) => T 产生一个 Reactive<Target>

通过 Computed 可以将整个 Reacive<{id:string,name:string}> 转换为视图相关的 Reacive<string>

// <div>
//     <div>{{val.id}}</div>
//     <div>{{val.name}}</div>
// </div>
class View {

    div = document.createElement('div')

    constructor(text: Reactive<{id:string,name:string}>) {
        
        this.render(text)
    }
    render(v: Reactive<{id:string,name:string}>) {
        this.div.innerHTML = ''
        const c1 = new Computed((m:{id:string,name:string})=>m.id,v)
        const c2 = new Computed((m:{id:string,name:string})=>m.id,v)

        const div1 = new Div(c1)
        const div2 = new Div(c2)
        this.div.appendChild(div1.div)
        this.div.appendChild(div2.div)
    }
}
复制代码

如此每次视图更新的时候仅仅需要重新计算与视图相关的 Computed ,如果变化则更新对应的绑定节点,无变化则不进行更新。

欸,这不正是 angular 脏检查的逻辑?

如今谈起 react 就会想到 “全局状态” ,谈起 vue 就会想到 “defineProperty/”,谈起 angular 就会想到“脏检查”。不可否认,这分别是三大框架的 “标签”,但是对于框架的认知仅仅停留在这几个标签上,除了能作为茶余饭后的谈资外并没有多大用处。

若是以 Reactive 将三者响应式更新的思路进行统一 ;以对于复杂对象与视图的绑定方式将三者对响应式更新的实现进行分化。这种思考方式则不仅能增进对框架实现的优势与缺陷的认识,同时也会对 “前端框架” 这个概念本身有更深入的理解。随着对 “前端框架” 的理解更加深入,自然就会想着动手实现一个框架以验证自己的想法。


这篇文章是接下来一系列 “自制前端框架” 文章的 intro ,也是鄙人对于目前关于“前端框架”的讨论的一个吐槽。

对于前端框架,永远不缺乏对某个功能的使用教程或是其实现原理的讨论,譬如“如何用 react 新特性 hook 开发组件”,“vue 利用 Proxy 实现数据绑定的原理” 等等。

可是面对层出不穷的新框架、新轮子,前端 er 们的心态由新奇到焦虑的原因,到底是行业发展太快,还是大家对于框架的认识仅停留在工具层面?

鄙人不才,想来聊聊。

文章分类
前端