浅析前端框架如何更新视图

854 阅读10分钟

目录

一、缘起
二、Prototype 与 jQuery
  Prototype
  jQuery
三、模板引擎
  实现原理
  jquery.tmpl
四、Virtual DOM
  简史
  初探
  传统 diff
  React
  Vue

一、缘起

1994 年,网景公司成立,发布了第一款商业浏览器 Navigator。之后,微软也推出了自家的 IE 浏览器。

同年,W3C 小组成立,负责 HTML 的发展路径,其宗旨是促进通用协议的发展。

之后的 1995 年,JavaScript 诞生了。

有传闻说是网景工程师布兰登·艾克(Brendan Eich)只花了 10 天时间就设计出来的。但也因为工期太短的缘故,还存在许多瑕疵,因此一直被 “正统” 传序员所嫌弃。

早期 JavaScript 没有包管理机制,也没有像 Java、C++ 那样的打辅助用的 SDK,内置的方法也很少。

还有就是性能问题,关于使用 eval 还是 Function,使用哪种循环方式,该用parseInit 还是 ~~ 等等的讨论都是为了提升那一点点的性能。

JavaScript 主要语言特征:

  • 借鉴 C 语言的基本语法;
  • 借鉴 Java 语言的数据类型和内存管理;
  • 借鉴 Scheme 语言,将函数提升到"第一等公民"(first-class citizen)的地位;
  • 借鉴 Self 语言,使用基于原型(Prototype)的继承机制。

wodeguo.jpg

二、Prototype 与 jQuery

Prototype、jQuery 等 js 库的出现,在完善 JavaScript 的语言特性的同时也提高了 JavaScript 的性能。

这两个 js 库均采用直接操作 Dom 的方式更新页面。

Prototype

这里说的 Prototype 不是我们现在熟知的对象的原型,而是一个名为 Prototype 的 js 基础类库。由 Ruby 语言的大牛 Sam Stephenson 所写。

在 prototype.js 中,prototype 对象是实现面向对象的一个重要机制。同时 Prototype 还创造了 Function.prototype.bind,并在数组上增加了一大堆方法,其中很多方法也已经被标准化了。

jQuery

2006 年,jQuery 发布。jQuery 发掘出大量的 DOM/BOM 兼容方案,解决了最头疼的浏览器兼容性问题。

2009 年,jQuery 成功研发出 Sizzle 选择器引擎,使其在性能上力压一众竞品,Dojo、Prototype、ExtJS、MooTools 等。同时在处理 DOM 兼容上,发掘出大量的 DOM/BOM 兼容方案。

jQuery 以 DOM 为中心,开发者可以选一个或多个 DOM,转变为 jQuery 对象,然后进行 链式操作。

开发者们已开始注重前后端分离,并要求不能污染 Object 原型对象,不能污染 window 全局变量。jQuery 仅占用两个全局变量。jQuery 精巧的源码实现使其大小压缩后不到 30KB,网上涌现出大量关于其源码详解的书藉。

jQuery 的出现也大大降低了前端门槛,让更多人进入了这个行业,我也是通过 jQuery 入的前端这个坑。

当时还有不少段子,“超市收银员边工作边看前端书籍,一个月后就直接去互联网公司做前端了”,诸如此类。

三、模板引擎

实现原理

在我们使用 jQuery 时需要解决大段 HTML 的生成问题,虽然有 $.html$.append$before 等方法,但是为了更好地管理不同的 HTML,我们想将 HTML 分离出来,让 HTML 独立到不同的文件中,然后直接插数据。

1994 年 PHP 诞生,实现了将动态内容嵌入 HTML,提升了编写效率和可读性,其界定符、循环语句等的发明,直接或间接地影响了 JavaScript 前端模板引擎的出现。

模板引擎可以简单用一个公式里描述:HTML = template(vars)

模板引擎的实现需要解决 模板存放、模板获取、模板解析编译 的问题

  • 模板存放:模板一般放置在 textarea/input 等表单控件,或 script 等标签中
  • 模板获取:通常会给模板设置 id,通过 id 获取
  • 模板解析编译
    • 需要将模板中的 JS 语句和 html 分离出来,不同模板引擎所用的分隔符也不太一样,常见的有 {{...}} 或是 <%...%>
    • 通过区别一些特殊符号比如 each、if 等来将字符串拼接成函数式的字符串,将两部分各自经过处理后,再次拼接到一起
    • 最后将拼接好的字符串采用 new Function() 的方式转化成所需要的函数。

jquery.tmpl

这里以 jquery.tmpl 为例,先来个小栗子

...
<body>
  <div id="div_demo"></div>
</body>
<!-- 模板1,测试${}、{{=}}标签的使用 -->
<script id="demo" type="text/x-jquery-tmpl">
  <div style="margin-bottom:10px;">
    <span>${id}</span>
    <span style="margin-left:10px;">{{= name}}</span>
    <span style="margin-left:10px;">${age}</span>
    <span style="margin-left:10px;">${number}</span>
  </div>
</script>
<script type="text/javascript">
  //手动初始化数据
  var users = [
    { id: 1, name: "xiaoming", age: 12, number: "001" },
    { id: 2, name: "xiaowang", age: 13, number: "002" },
  ];
  //调用模板进行渲染
  $("#demo").tmpl(users).appendTo("#div_demo");
</script>
...

jquery.tmpl 使用的模板存放于 id 为 demo 的 script 标签内

模板的读取依靠 jQuery 的选择器,直接以模板为主体,调用 tmpl 解析数据,调用 jQuery 自带的 appendTo 方法插入到父节点中

这里模板的解析结合源码看一下 (篇幅原因,省略了部分代码,完整代码看这里 buildTmplFn

function buildTmplFn(markup) {
  return new Function(
    "jQuery",
    "$item",
    "var $=jQuery,call,__=[],$data=$item.data;" +
      "with($data){__.push('" +
      jQuery
        .trim(markup) // 去前后空格
        .replace(/([\\'])/g, "\\$1") // 替换单引号
        .replace(/[\r\t\n]/g, " ") // 替换掉换行、退格符
        .replace(/\$\{([^\}]*)\}/g, "{{= $1}}") // 将 {{}} 语法通通换成 {{= }} 语法
        .replace(
          /\{\{(\/?)(\w+|.)(?:\(((?:[^\}]|\}(?!\}))*?)?\))?(?:\s+(.*?)?)?(\(((?:[^\}]|\}(?!\}))*?)\))?\s*\}\}/g,
          function (all, slash, type, fnargs, target, parens, args) {
            ...
            return (
              "');" +
              tag[slash ? "close" : "open"]
                .split("$notnull_1")
                .join(
                  target
                    ? "typeof(" +
                        target +
                        ")!=='undefined' && (" +
                        target +
                        ")!=null"
                    : "true"
                )
                .split("$1a")
                .join(exprAutoFnDetect)
                .split("$1")
                .join(expr)
                .split("$2")
                .join(fnargs || def.$2 || "") +
              "__.push('"
            );
          }
        ) +
      "');}return __;"
  );
}

buildTmplFn 也是通过处理模板字符,最终生成一个可执行的函数。模板的解析依靠正则实现,代码虽少但却实现了十分强大的模板能力。

最后返回的函数的函数体如下

var $ = jQuery,
  call,
  __ = [],
  $data = $item.data;
with ($data) {
  // === buildTmplFn 最后一个替换生成如下代码====
  __.push('<div style="margin-bottom:10px;">       <span>');
  if (typeof id !== "undefined" && id != null) {
    __.push($.encode(typeof id === "function" ? id.call($item) : id));
  }
  __.push('</span>       <span style="margin-left:10px;">');
  if (typeof name !== "undefined" && name != null) {
    __.push($.encode(typeof name === "function" ? name.call($item) : name));
  }
  __.push('</span>       <span style="margin-left:10px;">');
  if (typeof age !== "undefined" && age != null) {
    __.push($.encode(typeof age === "function" ? age.call($item) : age));
  }
  __.push('</span>       <span style="margin-left:10px;">');
  if (typeof number !== "undefined" && number != null) {
    __.push(
      $.encode(typeof number === "function" ? number.call($item) : number)
    );
  }
  __.push(
    "</span>     </div>"
    // =======
  );
}
return __;

最后生成的函数被执行,输出带有数据的 html 字符串,再插入到指定父节点中。

模板引擎更新视图的方式即 替换指定 Dom 元素的所有子节点。

当然也存在其弊端,有部分的替换会引起 回流。并且如果只是修改个别数据,使用模板时需要重新渲染整片区域,这是没有必要的,也是耗性能的。

四、Virtual DOM

简史

时间来到 2009 年 NodeJs 诞生,随着 NodeJS 的发展冒出一大堆模块、路由、状态管理、数据库、MVC 框架(Backbone.js 也属于 MVC 框架,强依赖于 jQuery)

之后大公司开始入局,MVVM 框架出现,比较有代表性的如:谷歌的 Angular,微软的 Knockout.js,苹果的 Ember.js,Facebook 的 React。

MVVM 的视图模型是一个值转换器,包括四个部分:

  • 模型 模型是指代表真实状态内容的领域模型(面向对象),或指代表内容的数据访问层(以数据为中心)。
  • 视图 就像在 MVC 和 MVP 模式中一样,视图是用户在屏幕上看到的结构、布局和外观(UI)。
  • 视图模型 视图模型是暴露公共属性和命令的视图的抽象。MVVM 没有 MVC 模式的控制器,也没有 MVP 模式的 presenter,有的是一个绑定器。在视图模型中,绑定器在视图和数据绑定器之间进行通信。
  • 绑定器 声明性数据和命令绑定隐含在 MVVM 模式中。在 Microsoft 解决方案堆中,绑定器是一种名为 XAML 的标记语言。绑定器使开发人员免于被迫编写样板式逻辑来同步视图模型和视图。在微软的堆之外实现时,声明性数据绑定技术的出现是实现该模式的一个关键因素。

图片来源:维基百科

大公司的介入,无疑给开发者带来巨大影响,毕竟 “迷信” 大公司是这一行的老传统了,jQuery 因为没有大公司支撑很快就被边缘化了。

2013 Facebook 将 React 开源,支持 JSX 语法,一开始这种写法让人难以接受,在 2017 年 Facebook 推出 React Native,人们才开始接受 JSX 这种写法,也开始研究其背后的 虚拟 DOM 技术。
(由于 JSX 需要额外编译,又间接促成了 Babel 与 webpack 的壮大)

谷歌在发布 Angular 时,同时发布了一个名为 Polymer 的框架,使用 Web Components 的浏览器自定义组件技术;虽然这个框架最后没火起来,但是它将 Script、Style、Template 三种内容混在一个文件的设计,成功启发了一个留美华人,搞出了 Vue.js,这人就是 尤雨溪


打成共识.jpg

最后提一下国内的特色终端——小程序

  • 底层运行的迷你 React 的虚拟 DOM
  • 内置组件是使用 Web Component
  • API 来源于 Hybird 的桥方法
  • 打包使用 webpack
  • 调试台是 Chrome console 的简化版
  • WXML、WXSS 的语法高亮也应该是 webpack 或 VS Code 的插件
  • 模块机制是 Node.js 的 CommonJS

(为了方便介绍,后文将使用 VD 指代 Virtual DOM)

初探

本质上来说,VD 只是一个简单的 JS 对象,基础属性包括 标签名(tag)、属性(props) 和 子元素对象(children)。不同的框架对这三个属性的命名会有点差别,但表达的意思基本是一致的。

以下是 Vue 中的 VD 结构

export default class VNode {
  tag: string | void;
  data: VNodeData | void;
  children: ?Array<VNode>;
  text: string | void;
  elm: Node | void;
  ns: string | void;
  context: Component | void; // rendered in this component's scope
  key: string | number | void;
  componentOptions: VNodeComponentOptions | void;
  componentInstance: Component | void; // component instance
  parent: VNode | void; // component placeholder node

  // strictly internal
  ...
}

下面是截取的 React 的 VD 结构,也就是 Fiber

export type Fiber = {
  tag: WorkTag,
  key: null | string,
  elementType: any,
  type: any,
  stateNode: any,
  return: Fiber | null,
  child: Fiber | null,
  sibling: Fiber | null,
  index: number,
  ...
|};

两边都存在 tag 属性,不同的地方是 Vue 中子节点存放于 children 中,而 React 通过 child 指向子节点,如果存在多个子节点,子节点再通过 sibling 属性连接上其余的子节点,如下图

reactVD.png

VD 与 Dom 对象具有一一对应的关系,借助 VD 将页面的状态抽象为 JS 对象,再配合不同的渲染工具,即可达到 跨平台渲染 的效果。

在进行页面更新的时候真实 DOM 的改变可以借助 VD 在内存中进行比较,也就是常说的 diff。

传统 diff

使用 VD 的框架,一般的设计思路都是 页面等于页面状态的映射,即 UI = render(state)。当需要更新页面的时候,无需关心 DOM 具体的变换方式,只需要改变 state 即可,剩下的事情(render)将由框架代劳。

当 state 发生变化时,我们重新生成整个 VD ,触发比较的操作。
上述过程大致可分为以下四步:

  • state 变化,生成新的 VD
  • 比较新旧 VD 的异同
  • 生成差异对象(patch)
  • 遍历差异对象并更新 DOM

这里我们讲一下传统 diff 算法,就是将新旧两棵 VD 树的节点依次进行对比,最后再进行真实节点的更新。

diff.png

如上图所示,左侧新 VD 上的节点需要一一与右侧旧 VD 的节点对比。为了后续方便计算时间复杂度,我们假设理想状况下新 VD 树的节点个数与旧 VD 树的节点个数都为 n。

很多文章都会直接告诉你,传统 diff 算法时间复杂度为 O(n^3),至于为什么,那是众说纷纭,这个说法的出处已经无从考证(有了解的小伙伴欢迎留言或私信)

疑惑.jpg

有两种普遍的说法:

  • 第一种是常规思路

    • 新 VD 树任一节点与旧 VD 树节点对比,时间复杂度为 O(n)
    • 而新 VD 树有 n 个节点,因此对比完新旧两棵树的所有节点,时间复杂度为 O(n^2)
    • 遍历完成后得到两棵树的差异对象,严格来讲这里还涉及到最小距离转换(transform cost)问题,这里我们可以简单理解为遍历旧 VD 树(当前真实节点)完成更新操作;最终时间复杂度为 O(n^3)
  • 第二种就复杂了,涉及到两棵树的编辑距离问题,讲从 1979 到 2011,将树的编辑距离算法的时间复杂度降到了 O(n^3),详情戳这里

最后说一下我的看法,我认为 O(n^3)这个值应该是取的早期主流 diff 算法的时间复杂度的均值,毕竟我们也不知道所谓的传统 diff 算法到底长什么样,哪些算法能被称为传统 diff 算法。

React

不打算展开,实在是篇幅不允许,后面再单独出一篇 React diff 算法的。

React 的 diff 算法有个核心思路,即:结合框架的事务机制将多次比较的结果合并后一次性更新到页面,从而有效地减少页面渲染的次数,提高渲染效率

Vue

这个也不打算展开,理由同上,Vue 还有个 3.0 版,更有的聊了。

Vue 的 diff 算法采用多指针(这里指索引下标非内存地址),有的文章说双指针,其实不止,严格来讲有四个指针:

  • 新 VD 队列的队首
  • 新 VD 队列的队尾
  • 旧 VD 队列的队首
  • 旧 VD 队列的队尾

首尾两个指针向中心移动,借助原生 JS 的内置方法,“实时” 地更新真实节点

同时与 React 一样,采用 key 来提升算法效率,借助 map 以空间换时间来降低 diff 算法的时间复杂度

这些介绍都比较笼统,顺手点个关注,来蹲一下 React/Vue diff 算法的解析?


文章同时发在个人公众号 MelonField

参考: