现代前端框架存在的根本原因

318 阅读8分钟

现代前端框架存在的根本原因

原文地址 The deepest reason why modern JavaScript frameworks exist

我见过很多前端开发者盲目的使用框架,Angular、React、Vue。确实是,这些框架提供了很多有意思的东西,但是,通常人们忽略了这些框架存在的最根本原因。

这个原因并不是因为这些框架:

  • 提供了组件式开发
  • 有很强的社区
  • 可以利用三方库来处理各种各样的问题
  • 有三方组件库
  • 有完善的浏览器插件方便调试
  • 擅长搭建spa

no.gif

最重要的、根本的、深层愿意按有且只有一个

reason.png

没错,就是它,为啥呢?

假设你在做一个web应用,用户可以通过邮件邀请其他人。设计师做了如下设计:

  • 初始状态下,有一个空白输入框,可以输入用户邮箱地址,下方带着一些帮助文案
  • 发起邀请之后,展示已经邀请的用户邮箱地址,并且可以删除

大概长这样

address.png

表单数据基本上可以抽象为一个对象数组,数组每一项有一个id和邮箱地址。 初始状态下,数组是空的,当输入邀请人的邮箱地址按下enter键之后,在数组内新增一项并且更新ui。当用户点击删除按钮的时候,删除数组中对应项并且更新ui

你品,你细品,每次更改数据都需要更新ui界面

BUT,这意味着什么?

我们来一起看下,如果不借助任何框架,使用纯js实现应该怎么做?

<!-- 页面结构 -->
<html>
  <body>
    <div id="addressList">
      <form>
        <input>
        <p class="help">Type an email address and hit enter</p>
        <ul></ul>
      </form>
    </div>
  </body>
</html>
class AddressList {
  constructor(root) {
    // state variables
    this.state = []
    
    // UI variables
    this.root = root
    this.form = root.querySelector('form')
    this.input = this.form.querySelector('input')
    this.help = this.form.querySelector('.help')
    this.ul = root.querySelector('ul')
    this.items = {} // id -> li element

    // event handlers
    this.form.addEventListener('submit', e => {
      e.preventDefault()
      const address = this.input.value
      this.input.value = ''
      this.addAddress(address)
    })
    
    this.ul.addEventListener('click', e => {
      const id = e.target.getAttribute('data-delete-id')
      if (!id) return // user clicked in something else      
      this.removeAddress(id)
    })
  }
  
  addAddress(address) {
    // state logic
    const id = String(Date.now())
    this.state = this.state.concat({ address, id })
    
    // UI logic
    this.updateHelp()
    
    const li = document.createElement('li')
    const span = document.createElement('span')
    const del = document.createElement('a')
    span.innerText = address
    del.innerText = 'delete'
    del.setAttribute('data-delete-id', id)
    
    this.ul.appendChild(li)
    li.appendChild(del)
    li.appendChild(span)
    this.items[id] = li
  }
  
  removeAddress(id) {
    // state logic
    this.state = this.state.filter(item => item.id !== id)
    
    // UI logic
    this.updateHelp()
    const li = this.items[id]
    this.ul.removeChild(li)
  }
  
  // utility method
  updateHelp() {
    if (this.state.length > 0) {
      this.help.classList.add('hidden')
    } else {
      this.help.classList.remove('hidden')
    }
  }
}

const root = document.getElementById('addressList')
new AddressList(root)

上面的代码很好的展示了,使用原生js实现一个稍微有点复杂的ui,所需要的工作量。

使用jquery也基本一样

上述例子中,页面的静态结构时使用HTML创建;动态结构是使用JS创建。那么第一个问题来了:我们需要使用2种方式来构建UI,并且使用JS创建UI可读性并不高。 当然,我们可以使用innerHTML来提高JS创建UI的可读性,但是这种做法性能不高,并且容易引起XSS攻击。我们也可以使用模板引擎,但是在构建一个超大的DOM时,存在2个问题: 性能问题和事件注册问题。

但是,这都不是最大的问题,最大的问题始终是:状态与UI的同步问题。 每一次修改数据,都需写一堆代码去手动更新UI。 上面例子中,新增一个邮箱地址,状态的更新只需要2行代码,但是由此导致的UI更新需要13行代码来实现。 即使是我们已经很大程度的简化的UI界面,真正的开发环境UI界面往往是更复杂的。

上述展示了手动维护状态与UI的同步问题的难度和复杂度,还有一个更重要的问题: 这种维护方式太脆弱了。 假设我在上述的基础上,增加了一个需求: 邮件列表需要与server端同步。 为了实现这个,我们需要从后端取数据然后比较,这就涉及到对比所有的id和邮件地址数据。

有可能出现本地数据与远端数据不一致的问题

我们需要写很多特定的、无法复用的代码来高效的操作DOM。 一旦我们犯了哪怕很小的一个错误,都会导致数据状态与UI界面的不一致:

  • 某些信息丢失
  • 展示错误信息
  • 对用户的交互没有按照预期的执行,甚至造成误操作

因此,为了维护状态与UI的一致性,我们不得不写很多防御性的代码

救世主: 声明式UI

因此,框架存在的根本原因,并不是繁荣的社区、或是配套工具、或是完善的生态、或是众多的三方库等其他原因

目前为止,这些框架相对原生JS最大的提升就是,他们提供了一套可靠的机制,帮助开发者实现了状态与UI的同步一致

忽略掉这些框架的条条框框的限制

有了这些框架,我们只需要在一个地方构建UI,不需要在每次状态变化的时候动态的去增删一些页面UI结构;然后对每个状态变动都有预期的输出。状态变化之后框架会自动的去更新。

怎么做到的呢?

框架实现状态与UI的同步,一般有2种方案

  1. 重新渲染整个组件: 代表是React。主要思路就是,当组件的状态更新,在内存中渲染一个DOM,然后与真实DOM对比。 但是因为DOM操作是比较耗费性能的,因此使用了虚拟DOM来替代。在状态更新之后,根据最新状态构建一个虚拟DOM,然后与根据当前页面真实DOM构建的虚拟DOM做对比,计算出变化的部分,然后更新到真正DOM中去。这个过程叫做协调。
  2. 使用watcher监测变化: angular和vue。框架维护了状态与使用了这些状态的组件依赖关系,一旦状态发生变化,依赖这些状态的组件会被更新。

我们说,vue的更新粒度比React的更新,这个说法的本质原因就在此。设计思路不一致,react没有去做依赖收集,状态变化之后,自顶而下的去更新组件

web components 是啥呢?

经常有人拿web components与React、Angular、Vue.js作比较。这也说明了很多人还是没有理解这些框架的核心功能: 保持状态与UI的同步。 这是web components没有提供的, 它只提供了一个template标签,并没有提供协调相关的机制。如果你使用web components开发web应用,你只能手动的维护应用的内部状态与UI的同步,或者使用像Stencil.js这样的三方库(它内部也是像React一样使用虚拟DOM)。

再次重申,这些框架的最核心功能并不是组件化,核心始终是状态与UI的一致性。Web components中这个功能并不是开箱即用的,需要配合第三方库或者手动维护。使用原生JS来实现复、高效的web应用是不可能的。 这才是这些现代框架存在的根本原因

自己实现一个

我喜欢去探究事物的本质,在框架中最核心的就是虚拟DOM的实现。那么,我们可以尝试借助使用现有的虚拟DOM方案来重新实现一下上述的需求,不借助任何现有的框架。

下面是核心的代码,组件的基类。

这里作者使用了 virtual-dom 这个库

// 组件基类
const dom = require('virtual-dom/h');
const diff = require('virtual-dom/diff');
const patch = require('virtual-dom/patch');
const createElement = require('virtual-dom/create-element');

module.exports.Component = class Component {
    constructor(root) {
        this.root = root;
        this.tree = h('div', null);
        this.rootNode = createElement(this.tree);
        this.root.appendChild(this.rootNode);

        this.state = this.getInitialState();
        this.update();
        this.componentDidMount();
    }

    setState(state) {
        this.state = Object.assign(this.state || {}, state);
    }

    update() {
        const newTree = this.render();
        const patches = diff(this.tree, newTree);
        this.rootNode = patch(this.rootNode, patches);
        this.tree = mewTree;
    }

    componentDidMount() {}

    getInitialState() {
        return {};
    }

    render() {
        return {};
    }

}

下面是AddressList邮箱地址列表组件的实现,需要使用babel转译来支持JSX语法

const { Component } = require('./component');

class AddressList extends Component {
    getInitialState() {
        return {
            addresses: []
        }
    }

    componentDidMount() {
        this.input = this.root.querySelector('input');

        // 事件注册
        // 添加邮件
        this.root.addEventListener('submit', e => {
            e.preventDefault();
            const address = this.input.value;
            this.input.value = '';
            this.addAddress(address);
        });
        // 删除
        this.root.addEventListener('click', e => {
            const id = e.target.getAttribute('data-delete-id');
            if (!id) return;
            this.removeAddress(id);
        })
    }
    // 现在增加邮箱之后,只需要更新数据即可,不需要开发者手动去更新ui界面了
    addAddress(address) {
        const id = String(Date.now());
        this.setState({
            addresses: this.state.addresses.concat({ address, id });
        })
    }

    removeAddress(id) {
        this.setState({
            addresses: this.state.addresses.filter(address => address.id !== id);
        })
    }

    render() {
        return (
            <div>
                <form>
                    <input />
                </form>
                {
                    this.state.addresses.length === 0 &&
                    (<p class="help">
                        Type an email address and hit enter
                    <p>)
                }
                <ul>
                    {this.state.addresses.map(addr => (
                        <li key={addr.id}>
                            <a data-delete-id={addr.id}>delete</a>
                            <span>{addr.address}</span>
                        </li>
                    ))}
                </ul>
            </div>
        );
    }
}

const root = document.getElementById('addressList');
new AddressList(root);

现在UI就是声明式的了,我们不需要使用任何框架。 我们可以根据逻辑需要随意修改数据状态,而不需要写额外代码来同步UI,问题解决!

至此,除了事件处理逻辑之外,这很像一个react应用,是吧?我们有render()componentDidMount()setState()等; 一旦解决了状态同步的问题,其他的就不是问题了。

代码仓库地址 ui-state-sync

结论

  • 现代框架解决的主要问题就是:状态与UI的同步问题
  • 使用原生JS是不可能实现一个复杂而又高效的web应用
  • web component不是用来解决这个问题的(状态与UI的同步问题)
  • 借助现有的虚拟DOM方案,可以很容易的实现一个你自己的框架,但是duck不必。