那些前端MVVM框架是如何诞生的

以下内容纯属扯淡,跟着文章思路慢慢看。。

刀耕火种时代

<p id="userInfo">
姓名:<span id="name">Gloria</span>
性别:<span id="sex"></span>
职业:<span id="job">前端工程师</span>
</p>

有以上html片段,想将其中个人信息替换为jabbla的,我们的做法:

document.getElementById('name').innerHTML = users.jabbla.name;
document.getElementById('sex').innerHTML = users.jabbla.sex;
document.getElementById('job').innerHTML = users.jabbla.job;

存在的问题

仔细想一想,这种开发方式,在users.jabbla和对应的html结构中间,总感觉有一层膜,除了需要在模板里定义某个元素的id外,还要在js中经过getDom(获取dom元素)和setDom(设置dom元素)操作。

有没有一种方法,可以帮助我们省去getDom和setDom,直接将users.jabbla和html结构对应起来。

当然有,由于innerHTML这个属性,我们可以利用模板系统。

引入模板

有了模板系统之后,我们可以写下这样的html片段:

<script id="userInfoTemplate">
姓名:<span>{name}</span>
性别:<span>{sex}</span>
职业:<span>{job}</span>
</script>

js中渲染模板,替换userInfo内容:

var userInfo = document.getElementById('userInfo');
var userInfoTemplate = document.getElementById('userInfoTemplate').innerHTML;
userInfo.innerHTML = templateEngine.render(userInfoTemplate, users.jabbla);

使用这种开发方式,省去了上面getDom和setDom的过程,可以说是"一气呵成"。

存在的问题

现在我们只实现了初始的内容渲染,如果需要在点击某个按钮切换用户信息:

<script id="userInfoTemplate">
姓名:<span>{name}</span>
性别:<span>{sex}</span>
职业:<span>{job}</span>
<button id="nextUserBtn">下个用户</button>
</script>

切换部分的逻辑:

var nextUserBtn = document.getElementById('nextUserBtn');
var currentUser;
nextUserBtn.addEventListener('click', function(){
    currentUser = users.Gloria;
    userInfo.innerHTML = templateEngine.render(userInfoTemplate, currentUser);
});

在这里,我们还是需要获取nextUserBtn元素,有没有不需要获取元素,在写userInfoTemplate的时候就指定好点击事件的方法呢?当然有,可以升级模板系统。

增强后的模板系统

我们想要的效果:

<script id="userInfoTemplate">
姓名:<span>{name}</span>
性别:<span>{sex}</span>
职业:<span>{job}</span>
<button cilck={nextUser()}>下个用户</button>
</script>

js代码:

var currentUser = users.jabbla;
function nextUser(){
    currentUser = users.Gloria;
    userInfo.innerHTML = templateEngine.render(userInfoTemplate, currentUser);
}

如何实现

可以看到,我们现在可以将事件直接放在userInfoTemplate中,然后在js中直接写替换逻辑,但是这个是如何实现的呢?

如果直接在html结构上绑定事件,事件处理函数无法获取到js中的作用域。所以换个思路,直接在html结构上绑定这个方式行不通。

想要获取函数的作用域,必须在dom元素上绑定。只要能将userInfoTemplate中的html结构解析成DOM树,然后遍历元素上的属性获取事件处理函数标识,再进行绑定就可以了。

于是在这一过程有很多种方案可以选择,但是思路都是先从userInfoTemplate生成一个类似Dom结构的Tree,再通过遍历Tree生成最终的DomTree。

template --> Tree --> DomTree

在这个阶段,现有轮子总体上可以分为以下3个派别:

1. 特定的template语法,使用Parser解析出来的AST生成目标DomTree。

2. 模板语法使用html规范,借助innerHTML,让浏览器自己生成一个带有template字符串的fakeDomTree,通过fakeDomTree生成最终设置好的DomTree。

3. 直接将userInfoTemplate的内容写在js中,通过预处理的方式将模板字符串转换成声明virtualDom结构的js代码,最终使用完整的virtualDom生成DomTree。

各自特点

1. 生成template和DomTree中间结构的过程不依赖浏览器环境,支持丰富的模板语法,但是其中Parser是在浏览器环境中运行,会带来一些性能问题,理论上首屏渲染速度会比其它两种方式逊色一点。

2. 直接利用浏览器构建fakeDomTree,可以说性能方面要比带Parser的方案好很多,理论上首屏渲染速度要比第1种好很多。但是这种方案强依赖于浏览器环境,而浏览器的不同实现是不稳定因素。

3. 通过对模板语法的预处理,直接生成virtualDom结构声明的代码,理论上这种方案是3个方案中首屏渲染速度最快的,它没有从template到virtualDom这一过程。比较受争议的一点是:模板与js必须写在一起,有人说这种方案是图灵完备的,模板也具有完整的编程能力。又有声音说:html,css,js写在一起的这种方式是有悖潮流的。见仁见智吧。

存在问题

使用以上讨论的三种方式解决了事件绑定之后,还有一个问题需要我们亟待解决。

可以看到,上面我们在点击“下一个用户”按钮之后,会再次调用templateEngine.render()这个方法。例子中还只是一个html片段,而现在我们写的template是整个页面的模板,如果再重新渲染整个页面,重复template``到DomTree这一过程,一点非常小的改动都会导致整个页面的重新渲染,这样会使得页面性能会非常差。

所以,我们需要局部更新功能。

引入局部更新

什么是局部更新呢?

为了避免整个页面重新渲染,使得在每次数据发生变化之后,只渲染那些需要更新的部分。

更新策略

主流框架分为两个派别:

1. 将Dom元素与数据绑定在一起(创建观察者),当数据发生变化之后,执行更新Dom元素的操作

2. 对比数据变化前后生成的两个类Dom结构树,将改变映射到真实的Dom树

对比:

1. 会创建很多观察者常驻内存,随着页面越来越复杂,性能可能是个问题

2. 每次改变都会重新生成新的Dom元素,所以数据与Dom元素无法形成绑定的关系。另外一点,diff算法的好坏直接决定了局部更新的性能。

检测变化

三种方式:

1. 脏检查:遍历观察者,判断改变前后值是否发生变化,也就是脏检查,是主动的。

2. 懒检测:劫持数据的改变行为,每当行为发生,就做一次变化检测,是"懒"的,在需要的时候进行。

3. 不检测:不关心某个数据是否已经发生变化,只关心前后两个最后输出的类Dom结构的变化。

方案组合

结合更新策略和检测变化的几种方案,得到局部更新的几个方案

基于脏检查

1. 脏检查+数据绑定Dom元素:在某些特定时刻,自动执行脏检查,更新脏数据对应的Dom元素。

2. 脏检查+类Dom结构树对比:脏检查检测的单位可以具体到每个属性,这样会让类Dom结构树对比的范围更加精确,diff算法的性能会更好。

这两种方案都比较依赖脏检查,而脏检查最大的缺陷就是性能问题,它会遍历所有的观察者,不管这些观察者对应的数据是否已经发生变化。所以说这两种方案最大的缺陷就是脏检查的性能问题。

基于懒检测

1. 懒检测+数据绑定Dom元素:每次属性的改变行为发生,对比前后值,更新对应Dom元素

2. 懒检测+类Dom结构树对比:与脏检查的方案一致,都会让类Dom结构树的对比范围更加精确,会有比较好的diff算法效率。

看上去这种基于懒检测的方案,比脏检查的方案好很多,其实未必,如果不进行特殊处理,频繁发生数据改变行为,Dom元素会频繁更新或者频繁运行diff算法。需要做的就是合并一定时期内的所有变化,统一进行Dom更新或者diff。

基于不检测

1. 不检测+数据绑定Dom元素:这种方案显然行不通,不检测数据变化我怎么更新Dom。。

2. 不检测+类Dom结构树对比:手动触发数据集合到类Dom结构树的映射,对比前后两棵树,将改变映射到真实的Dom树。

这种方案对比前两种优势是不会有很多观察者常驻内存,不会频繁触发更新。而在diff算法效率方面,前两种方案会比较有优势。

存在的问题

现在,貌似已经解决了大部分的问题,这么多方案已经足够解决局部更新这个问题了。

又有个问题来了,如果我想复用下面这个结构片段,页面希望存在一个用户信息列表,怎么办?

<script id="userInfoTemplate">
    <p>
        姓名:<span>{name}</span>
        性别:<span>{sex}</span>
        职业:<span>{job}</span>
        <button cilck={nextUser()}>下个用户</button>
    </p>
</script>

按照之前的思路,我们想在构建视图的时候就需要声明式地复用这个userInfoTemplate片段。

引入可复用概念

声明式地复用,可以像下面这样:

<p>
    用户信息列表:
    <userInfo name={gloria.name} sex={gloria.sex} job={gloria.job}></userInfo>
    <userInfo name={jabbla.name} sex={jabbla.sex} job={jabbla.job}></userInfo>
</p>

如何实现

在之前解决事件绑定的问题时候,已经分析出来了几种解决方案,现在回顾一下:

1. 特定的template语法,使用Parser解析出来的AST生成目标DomTree。
2. 模板语法使用html规范,借助innerHTML,让浏览器自己生成一个带有template字符串的fakeDomTree,通过fakeDomTree生成最终设置好的DomTree。
3. 直接将userInfoTemplate的内容写在js中,通过预处理的方式将模板字符串转换成声明virtualDom结构的js代码,最终使用完整的virtualDom生成DomTree。

基于前两种方案

从template到最终DomTree,中间会生成一个用于生成DomTree的类Dom结构,不管它是AST或者fakeDomTree,本质都是一样的,我们都需要walk(遍历)这个中间产物(下面的文章称之为"Tree")。

当编译器遇到类似userInfoTemplate这样的自定义标记时,就得将这个元素视为一个可复用单位(我们习惯称之为"组件"),然后在当前作用域内寻找该组件的定义。

组件定义

什么是组件定义呢?

首先得包括几个基本元素,1. 模板,2. 状态,3. 事件处理函数 函数定义,可描述组件的这几个基本元素的可复用数据结构,目前来看只有"类"了,比如像下面这样:

//组件抽象
class Component {
    constructor(options){
        this._template = options.template;
        this._state = options.state || {};
        ....
    }
    ....
}

//自定义组件
class UserInfo extends Component {
    constructor(options){
        super(options);
        Object.assign(this._state, {})
    }
    nextUser(){}
}

像下面这样就是实例化一个组件:

<userInfo name={gloria.name} sex={gloria.sex} job={gloria.job}></userInfo>

在遍历Tree的时候,遇到自定义标记,然后将gloria.name,gloria.sex,gloria.job这三个值赋值给options._state.name,options._state.sex,options._state.job并且实例化这个组件,那我们如何确定<userInfo>这个标记就是UserInfo组件呢?接下来就要谈作用域了。

作用域

有人可能就问了,为什么还有个"作用域"呢?因为考虑到这里的组件标记会被重复定义,如果所有的组件公用一个命名空间,随着页面规模越来越大,很容易导致命名冲突的情况。

在这里暂且划分为两类作用域,全局作用域和组件作用域。在全局作用域注册的组件在任何作用域内都能寻找到该组件的定义,而在组件作用域内注册的组件只能在该作用域内可以寻找到相关的组件定义。

比如可以像下面这样注册组件:

Component.register('userInfo', UserInfo); //全局注册UserInfo
UserInfo.register('xxx', SubComponent); //在UserInfo组件作用域内注册SubComponent

基于第3种方案

这种方案使得我们可以在js中写模板,而且这种模板具有完整的js编程能力,所以,除了上面说的"类",我们还能使用"函数"达到复用目的。

这种方案组件中的基本元素和上面的差不多,只不过少了"模板",因为模板在预处理阶段已经被转化成了声明virtualDom的js代码。

而且这种方案中没有"作用域"概念,作用域的出现只是为组件提供了一个命名空间,方便解析模板时找到相应的组件定义,现在"模板"已经写在了js中,就无需再额外定义组件名称寻找相关定义了。

总结

在引入组件化之后,其实还有很多情况需要考虑,比如生命周期,组件通信等等,不过这些都不在这篇文章的考虑范围。

以上已经总结了前端MVVM框架中比较基础也是最重要的东西,其它功能其实都是围绕这些核心脉络来拓展的。

从模板方案-->局部更新-->组件化,经历过这几个阶段的洗礼其实最后会呈现出各种方案组合,于是就看到了现在层出不穷的框架。


永远年轻,永远热泪盈眶。