技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

50行代码的MVVM,感受闭包的艺术

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/02-50行代码的MVVM,感受闭包的艺术
本文目录9 个章节
  1. 原作者的“50 行”核心代码(历史教学实验)
  2. 追加:安全改良版最小实验
  3. 改良版重点分析:闭包到底发生在哪里
  4. 依赖收集与更新链路
  5. 为什么它不是完整双向绑定
  6. 示例局限
  7. Vue 2 与 Vue 3 对照
  8. 结论
  9. 官方参考

50行代码的MVVM,感受闭包的艺术

Category(分类): Vue Status: 已校订(Vue 2 风格教学实验)

版本说明:原文把示例称为“MVVM/双向绑定”,但它实际只实现了 Data → View 的浅层响应式文本更新,没有输入事件或 v-model,也不是 Vue 源码的 50 行复刻。下面先保留原作者的“50 行”核心代码及分析,再紧邻说明局限;安全改良版作为追加内容,不替代原实验。

我们先看原运行结果:

原文配图 02-01

nameage 初次渲染出来;2 秒后,脚本修改 name,页面随之更新。这里是代码修改 Model 后触发 View 更新,不是用户操作 View 后写回 Model。

原作者的“50 行”核心代码(历史教学实验)

class Vue {
  constructor(opt) {
    this.opt = opt
    this.observe(opt.data)
    const root = document.querySelector(opt.el)
    this.compile(root)
  }

  observe(data) {
    Object.keys(data).forEach((key) => {
      const obv = new Observer()
      data[`_${key}`] = data[key]
      Object.defineProperty(data, key, {
        get() {
          Observer.target && obv.addSubNode(Observer.target)
          return data[`_${key}`]
        },
        set(newVal) {
          obv.update(newVal)
          data[`_${key}`] = newVal
        },
      })
    })
  }

  compile(node) {
    ;[].forEach.call(node.childNodes, (child) => {
      if (!child.firstElementChild && /\{\{(.*)\}\}/.test(child.innerHTML)) {
        const key = RegExp.$1.trim()
        child.innerHTML = child.innerHTML.replace(
          new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'gm'),
          this.opt.data[key],
        )
        Observer.target = child
        this.opt.data[key]
        Observer.target = null
      } else if (child.firstElementChild) {
        this.compile(child)
      }
    })
  }
}

class Observer {
  constructor() {
    this.subNode = []
  }

  addSubNode(node) {
    this.subNode.push(node)
  }

  update(newVal) {
    this.subNode.forEach((node) => {
      node.innerHTML = newVal
    })
  }
}

原作者的执行思路是:

  1. observe() 遍历 data 的已有 key,每个 key 创建一个 Observer;getter 读取时把当前 DOM 节点登记进去,setter 写入时通知这些节点。
  2. compile() 从根节点递归扫描 DOM,找到 {{ key }},先替换初始值,再借助 Observer.target 和一次属性读取完成登记。
  3. 2 秒后的赋值直接触发 setter,Observer.update() 同步修改节点。

闭包确实存在,但原文“for 循环每次执行都形成闭包”“内层作用域中堆地址暴露”的说法不准确。这里是 forEach 回调每次调用形成独立词法环境,getter/setter 捕获当次的 keyobv;闭包是函数和其创建时词法环境中外部变量引用的组合。

这段代码还不是完整双向绑定:它没有任何 View → Data 的输入事件。另有几个重要局限:

  • data['_' + key] 会污染用户数据;若用户本来就有 _name,还会与 name 的内部存储冲突。遍历期间新增下划线属性也使数据形态变得意外。
  • 描述符未显式设置 enumerableconfigurable,二者默认 false,与普通对象原属性行为不同。
  • innerHTML 会把数据当 HTML 解析,既可能破坏原节点的子结构,也可能在数据不可信时造成 XSS;mustache 本应使用纯文本语义。
  • 正则是贪婪匹配,一个文本节点多个插值、复杂表达式、嵌套对象、数组、新增字段等都未正确支持。
  • Observer.target 没有栈和异常恢复,订阅不去重、不清理;更新也没有 Vue 的 VNode patch 与异步调度。
  • “后续 event loop 修改 key”不是更新成立的原因;同步赋值同样会立即触发这个 demo 的 setter。

原作者完整页面演示

下面保留原实验的完整运行形态,只清理抓取粘连和格式;其限制与上文相同,请勿作为生产实现:

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>50 行 MVVM 历史实验</title>
  </head>
  <body>
    <div id="app">
      <h3>姓名</h3>
      <p>{{ name }}</p>
      <h3>年龄</h3>
      <p>{{ age }}</p>
    </div>

    <script>
      document.addEventListener('DOMContentLoaded', () => {
        const opt = {
          el: '#app',
          data: { name: '检索中...', age: 30 },
        }
        new Vue(opt)
        setTimeout(() => {
          opt.data.name = '王永峰'
        }, 2000)
      })

      class Vue {
        constructor(opt) {
          this.opt = opt
          this.observe(opt.data)
          this.compile(document.querySelector(opt.el))
        }

        observe(data) {
          Object.keys(data).forEach((key) => {
            const obv = new Observer()
            data[`_${key}`] = data[key]
            Object.defineProperty(data, key, {
              get() {
                Observer.target && obv.addSubNode(Observer.target)
                return data[`_${key}`]
              },
              set(newVal) {
                obv.update(newVal)
                data[`_${key}`] = newVal
              },
            })
          })
        }

        compile(node) {
          ;[].forEach.call(node.childNodes, (child) => {
            if (!child.firstElementChild && /\{\{(.*)\}\}/.test(child.innerHTML)) {
              const key = RegExp.$1.trim()
              child.innerHTML = child.innerHTML.replace(
                new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'gm'),
                this.opt.data[key],
              )
              Observer.target = child
              this.opt.data[key]
              Observer.target = null
            } else if (child.firstElementChild) {
              this.compile(child)
            }
          })
        }
      }

      class Observer {
        constructor() {
          this.subNode = []
        }
        addSubNode(node) {
          this.subNode.push(node)
        }
        update(newVal) {
          this.subNode.forEach((node) => {
            node.innerHTML = newVal
          })
        }
      }
    </script>
  </body>
</html>

原文完整演示的 GitHub 地址 可用于对照,但不要把它视为 Vue 源码。

追加:安全改良版最小实验

下面仍采用 Vue 2 风格的 Object.defineProperty,但使用闭包保存属性值,不再创建可能与用户字段冲突的 _name;更新专用文本节点而不是使用有 XSS 风险、且会破坏子节点的 innerHTML

class MiniReactiveView {
  constructor(options) {
    this.options = options
    this.observe(options.data)

    const root = document.querySelector(options.el)
    if (!root) throw new Error(`找不到根节点:${options.el}`)
    this.compile(root)
  }

  observe(data) {
    Object.keys(data).forEach((key) => {
      const dep = new Dep()
      let value = data[key]

      Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get() {
          if (Dep.target) dep.add(Dep.target)
          return value
        },
        set(newValue) {
          if (Object.is(newValue, value)) return
          value = newValue
          dep.notify(newValue)
        },
      })
    })
  }

  compile(root) {
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
    const textNodes = []

    while (walker.nextNode()) textNodes.push(walker.currentNode)

    textNodes.forEach((node) => {
      const source = node.textContent ?? ''
      const matches = [...source.matchAll(/\{\{\s*([A-Za-z_$][\w$]*)\s*\}\}/g)]
      if (!matches.length) return

      const render = () => {
        node.textContent = source.replace(
          /\{\{\s*([A-Za-z_$][\w$]*)\s*\}\}/g,
          (_, key) => String(this.options.data[key] ?? ''),
        )
      }

      matches.forEach(([, key]) => {
        Dep.target = render
        try {
          this.options.data[key]
        } finally {
          Dep.target = null
        }
      })

      render()
    })
  }
}

class Dep {
  static target = null

  constructor() {
    this.subscribers = new Set()
  }

  add(subscriber) {
    this.subscribers.add(subscriber)
  }

  notify() {
    this.subscribers.forEach((subscriber) => subscriber())
  }
}

完整页面:

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>最小响应式文本更新实验</title>
  </head>
  <body>
    <div id="app">
      <h3>姓名</h3>
      <p>当前姓名:{{ name }}</p>
      <h3>年龄</h3>
      <p>{{ age }} 岁</p>
    </div>

    <script>
      // 此处放入上面的 MiniReactiveView 与 Dep
      const options = {
        el: '#app',
        data: { name: '检索中...', age: 30 },
      }

      new MiniReactiveView(options)

      setTimeout(() => {
        options.data.name = '王永峰'
      }, 2000)
    </script>
  </body>
</html>

改良版重点分析:闭包到底发生在哪里

Object.keys(data).forEach((key) => { ... }) 的 callback 每次调用都有自己的词法环境。定义在其中的 getter/setter 会捕获当次调用的 valuedep,所以函数离开后,这些状态仍能被对应属性访问。

这才是闭包的准确含义:函数与其创建时所处词法环境中外部变量引用的组合。不是“for 循环每执行一次就自动形成闭包”,也不是“内层作用域的堆地址暴露”。

如果改用传统 for 并以 var 声明循环变量,多个闭包可能共享同一个绑定;let/const 则提供块级或每次迭代绑定。本例使用 forEach callback,因此每次回调自然拥有独立参数 key

依赖收集与更新链路

  1. observe() 为每个已有 key 创建一个 Dep,getter/setter 通过闭包持有它。
  2. compile() 扫描 Text 节点,找到简单的 {{ key }} 插值。
  3. 收集时暂时把 render 放到 Dep.target,读取 data[key] 触发 getter,将其登记到对应 Dep。
  4. 外部代码赋值触发 setter,setter 通知 Dep,render 重新计算该文本节点。

这里的更新是 demo 的 setter 同步调用,不是 event loop 主动发现了变量变化。真实 Vue 会批处理组件更新,DOM 通常在下一次 tick 完成。

为什么它不是完整双向绑定

示例没有 input/change 监听,所以没有 View → Data。若只针对文本输入,可以补一个最小事件回路:

<input id="name-input">
const input = document.querySelector('#name-input')
input.value = options.data.name
input.addEventListener('input', (event) => {
  options.data.name = event.target.value
})

这只是某一种控件的写回方式。Vue 的 v-model 会根据文本框、复选框、单选框、select 和组件选择不同属性/事件;Vue 3 组件 v-model 默认展开为:

<Child
  :model-value="name"
  @update:model-value="name = $event"
/>

因此应区分:响应式负责状态读取跟踪和变化通知,v-model 负责将表单/组件事件写回状态。

示例局限

这个几十行实验有意省略了生产框架必须处理的内容:

  • 只观测初始化时已有的顶层属性,不支持嵌套对象、数组和新增/删除字段;
  • 插值只支持简单标识符,不支持 Vue 模板表达式;
  • 没有依赖栈、失效依赖清理、组件卸载清理或调度队列;
  • 同一属性被多个节点读取虽可订阅,但没有 Vue 的组件/VNode patch;
  • 直接操作 DOM,不包含编译器、生命周期、computed、watch、methods 或组件系统;
  • 只实现 Data → View;即使增加上面的 input 监听,也只覆盖最简单文本输入。

使用 textContent 很重要:Vue 的 mustache 默认输出纯文本;不可信字符串不能直接交给 innerHTMLv-html,否则可能造成 XSS。

Vue 2 与 Vue 3 对照

维度本文 / Vue 2 风格Vue 3 当前机制
对象拦截遍历 key,使用 Object.definePropertyreactive() 返回 Proxy
原始值示例直接包装对象属性ref().value getter/setter
依赖单一 Dep.target + callback 集合track / trigger + ReactiveEffect
更新时机demo 同步更新 Text 节点Vue 批处理组件 DOM 更新
双向写回未实现;需手写 DOM 事件v-model 组合属性与事件

Vue 3 的 Proxy 能拦截对象新增、删除、枚举等更多操作;ref 则仍通过 .value accessor 跟踪读写。闭包仍广泛存在于 JavaScript 实现中,但“闭包”不等于“Vue 响应式”。

结论

原示例最值得保留的是:getter 在读取时登记依赖,setter 在写入时通知依赖,以及 getter/setter 对每个字段私有 dep/value 的闭包捕获。准确名称应是“Vue 2 风格的浅层响应式文本更新实验”,而不是完整 MVVM、完整双向绑定或 Vue 源码实现。

官方参考

作者:maodayeyeye 原文链接:https://juejin.cn/post/6844903619808985095 来源:稀土掘金。著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS