以下内容纯属扯淡, 跟着文章思路慢慢看..
刀耕火种时代
<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 框架中比较基础也是最重要的东西, 其它功能其实都是围绕这些核心脉络来拓展的.
从模板方案 --> 局部更新 --> 组件化, 经历过这几个阶段的洗礼其实最后会呈现出各种方案组合, 于是就看到了现在层出不穷的框架.
来源: https://juejin.im/entry/5aed522b51882567236ea442