Vue 札记

Basic Concepts

Template Compilation

模板编译是指将 Vue Template Syntax 转换为可执行的 JavaScript 渲染函数的过程,这个渲染函数通常被命名为 render 函数。render 函数中可以使用 createElement 辅助函数(用于创建虚拟 DOM 节点)来描述组件的渲染结构。

Vue 中 h 函数是 createElement 函数的 ES6 语法缩写,源自 virtual-dom 中的 hyperscript 脚本。

render: function(createElement){ return createElement(App) }
const h = (tag, props, children) => {return {tag, props, children,...}}
render: h => h(App)

Architectural Patterns

MVC 与 MVVM 都实现了数据、视图和业务逻辑的分离,但在角色和数据流方面有一些区别:

  • Model-View-Controller 中,数据流是单向的。控制器接收用户输入,更新模型数据,然后通知视图进行更新。
  • Model-View-ViewModel 中,虽然视图和模型不会直接通信,但是数据流是双向的。视图与模型之间会通过视图模型来进行自动更新。

Data Hijacking & Proxy

数据劫持是当对象的属性被访问或赋值时,将会触发预定义的方法,从而可以在这些方法中进行相应的操作,比如依赖收集和派发更新通知。

数据代理是在实例化期间,将数据对象的属性定义为实例上的访问器属性,当访问或赋值实例上的属性时,实际上是在访问或赋值数据对象上的对应属性。这种方式可以简化访问和绑定数据的语法。

在 Vue 中,通过使用 Object.definePropertydata 对象的属性添加到 vm 实例上,可以实现数据代理。这样在模板中就无需直接操作 _data.属性,而是可以通过 vm 对象来访问和修改属性。

Publish–Subscribe In Vue

发布订阅模式和观察者模式的区别主要在于是否引入了事件总线作为通信的中介,以实现松耦合的通信方式。

Vue 中的 Dep 充当了中介者的角色。每个数据属性都有对应的 Dep 实例,它维护着依赖该属性的一组 Watcher 对象。当数据属性被读取时,Watcher 会将自身添加到该属性的 Dep 实例中,建立起依赖关系。当数据发生变化时,Dep 实例负责遍历其中的所有 Watcher 对象,并调用它们的更新方法,通知它们进行更新操作。

Source Code Analysis

本文中的源码分析参考于 Vue2.6.x(Vue2 的最后一个稳定版本),较试验性版本 Vue2.7.x 会具有更佳的稳定性与成熟度。

Dependency Collection

Vue 通过 initState 初始化状态,该函数定义在源码 src/core/instance/state.js 中。

export function initState (vm: Component) {
  // 为组件实例添加一个空数组,用于存储后续创建的 Watcher 实例
  vm._watchers = []
  // 将组件实例的选项赋值给 opts 变量
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    // 组件没有 data 数据则创建一个空的观察对象并赋值给 vm._data,并将其标记为根数据
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  // 如果组件有 watch 监听器,且 watch 不等于 Vue 内置的原生 watch 实现,则调用 initWatch 函数对其进行初始化
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

每个组件实例都有自己的观察者列表,存放该组件内用到的所有状态的依赖。当其中一个状态发生变化时,会通知到组件的观察者,然后组件内部使用虚拟 DOM 进行数据比对。

其中 initPropsinitData 方法都调用了 observe 函数来将数据转化为响应式。observe 方法定义在 src/core/observer/index.js 中。当传入的值是一个非 VNode 的对象类型数据时,它会尝试为该值创建一个 Observer 实例,并将该值转化为响应式对象。

export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  // 如果该值已经被观察过(存在 ob 属性),则直接返回已存在的 Observer 实例
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

Observer 类的构造函数中,首先会实例化一个 Dep 对象用于依赖管理。然后通过 def 函数将当前 Observer 实例添加到数据对象 value__ob__ 属性上。最后根据 value 进行处理。

export class Observer {
  value: any;
  dep: Dep; // 依赖管理器
  vmCount: number; // 作为根 $data 的组件实例数量
  constructor (value: any) {
    this.value = value
    this.dep = new Dep() // 创建依赖管理器
    this.vmCount = 0
    def(value, '__ob__', this) // 判断一个对象是否已经被观察过
    if (Array.isArray(value)) { // 在值为数组的情况下,要判断浏览器是否支持 __proto__ 属性
      if (hasProto) { // 通过修改原型链,可以让数组对象直接调用响应式数组的方法
        protoAugment(value, arrayMethods)
      } else { // 将响应式数组的方法拷贝到目标数组对象上
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
  // 遍历对象的所有属性,将其转化为 getter/setter
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
  // 观察数组中的每个元素
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

Observer 类中的 Dep 对象用于侦测整个对象的变化;defineReactive 函数中的新的 Dep 对象用于侦测单个属性的变化。这样可以实现对对象及其属性的精确依赖追踪和响应式操作。

export function defineReactive(
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 创建一个依赖管理器
  const dep = new Dep();

  // 检查属性的描述符,如果属性不可配置,则返回
  const property = Object.getOwnPropertyDescriptor(obj, key);
  if (property && property.configurable === false) {
    return;
  }

  // 处理预定义的 getter 和 setter
  const getter = property && property.get;
  const setter = property && property.set;
  if ((!getter || setter) && arguments.length === 2) {
    // 如果没有 getter 或者有 setter,并且只有 2 个参数,则将 val 设置为 obj[key] 的值
    val = obj[key];
  }

  let childOb = !shallow && observe(val);

  // 使用 Object.defineProperty 定义属性的 getter 和 setter
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      const value = getter ? getter.call(obj) : val;
      if (Dep.target) {
        // 收集依赖
        dep.depend();
        if (childOb) {
          childOb.dep.depend();
          if (Array.isArray(value)) {
            // 如果值是数组,则收集数组元素的依赖
            dependArray(value);
          }
        }
      }
      return value;
    },
    set: function reactiveSetter(newVal) {
      const value = getter ? getter.call(obj) : val;
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        // 值没有发生变化时,不执行更新操作
        return;
      }
      
      ...
      
      // 如果有 setter,则调用 setter,否则直接更新 val 的值
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      // 对新值进行观测
      childOb = !shallow && observe(newVal);
      // 通知依赖进行更新
      dep.notify();
    },
  });
}

使用 Object.defineProperty 定义属性的 gettersettergetter 是在读取属性时进行依赖收集,setter 是在属性变化时进行依赖通知和更新操作。需要注意的是,Vue2 的这种响应式系统设计仅能监听对象属性的读取和写,无法监听到属性的添加和删除。

  • 当属性的 getter 被访问时,Dep.depend() 方法会被调用,将当前的 Dep 实例添加到当前的订阅者 Dep.target 的依赖列表中,实现依赖的收集。
  • 当属性的 setter 被调用时,Dep.notify() 方法会被调用,遍历 subs 数组,依次通知每个订阅者 watcher 属性的变化,触发更新操作。
export default class Dep {
  static target: ?Watcher; // 用于存储当前正在计算的 Watcher 对象
  id: number; // Dep 的唯一标识
  subs: Array<Watcher>; // 订阅该 Dep 的所有 Watcher 对象数组

  constructor () {
    this.id = uid++ // 唯一标识自增
    this.subs = [] // 订阅该 Dep 的 Watcher 对象数组
  }

  addSub (sub: Watcher) {
    this.subs.push(sub) // 将 Watcher 对象添加到订阅列表中
  }

  removeSub (sub: Watcher) {
    remove(this.subs, sub) // 从订阅列表中移除指定的 Watcher 对象
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this) // 将当前 Dep 对象添加到当前计算的 Watcher 的依赖列表中
    }
  }

  notify () {
    // 先稳定订阅者列表的顺序
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      // 如果非异步更新且不处于生产环境
      // 则需要手动对订阅者列表进行排序,以确保它们按正确的顺序触发更新
      subs.sort((a, b) => a.id - b.id)
    }
    // 遍历订阅者列表,触发每个订阅者的更新操作
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

Dep 类位于 src/core/observer/dep.js 文件中。每个属性所对应的 Dep 实例都会具有唯一的标识,用于区分不同的依赖。Dep 实例的 subs 数组用于存储订阅该依赖的订阅者 watcher。

每个组件实例都对应各自的 Watcher 实例。Watcher 实例会在组件的 beforeMount 阶段创建。

每个 Watcher 实例都会订阅一个或多个依赖(Dep 实例)。当订阅的依赖发生变化时,Dep 实例会通知相应的 Watcher 实例执行更新操作。

export default class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm // Vue 实例
    this.cb = cb // 回调函数
    // ...其他属性

    this.getter = parsePath(expOrFn) // 解析表达式或函数,生成一个读取数据的函数
    // ...其他初始化逻辑

    this.value = this.lazy
      ? undefined
      : this.get() // 初始化 value,若非 lazy,则执行 get() 方法获取初始值
  }

  get () {
    // 设置当前 Watcher 为 Dep.target,以便进行依赖收集
    pushTarget(this)
    let value
    const vm = this.vm
    // ...获取数据的逻辑
    // 收集完依赖后,恢复之前的 Dep.target
    popTarget()
    return value
  }

  update () {
    // ...更新逻辑
    // 调用 this.cb 回调函数,进行响应式更新
    this.cb.call(this.vm, value, oldValue)
  }
}

Observer 会把原始对象的每个属性通过 Object.defineProperty 数据劫持转换为带有 gettersetter 的属性(响应式对象)。当 render 函数在执行过程中访问到响应式数据时,会触发属性的 getter 方法。在 getter 方法中会通过 dep.depend() 来收集依赖,将当前的 Dep 对象添加到 Watcher 对象的依赖列表中。当响应式数据发生变化时,该数据所对应的 setter 方法就会被触发,执行 dep.notify() 通知与该数据相关的 Dep 对象。Dep 对象会遍历其中存储的所有 Watcher 对象,并调用每个 Watcher 对象的更新方法,即 subs[i].update(),此时会重新运行其对应的更新函数(通常是 render 函数)。

Diff & VNode

在 Vue 中,虚拟 DOM 的实现是基于 Snabbdom.js 库。该库用于处理 DOM 的创建、更新和渲染。

npm install -S snabbdom
npm install -D webpack webpack-cli webpack-dev-server
const path = require('path')
// const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
  entry: './src/index.js',
  output: {
    publicPath: '/virtual',
    filename: 'bundle.js'
  },
  devServer: {
    port: 8080,
    contentBase: 'www' // 静态资源目录
  }
}
<div id="container"></div>
<script src="/virtual/bundle.js"></script>
console.log("bundle.js import completed");
import {
    init,
    classModule,
    propsModule,
    styleModule,
    eventListenersModule,
    h,
} from "snabbdom";

const patch = init([
  // Init patch function with chosen modules
  classModule, // makes it easy to toggle classes
  propsModule, // for setting properties on DOM elements
  styleModule, // handles styling on elements with support for animations
  eventListenersModule, // attaches event listeners
]);

const container = document.getElementById("container");

const vnode = h("div#container.two.classes", { on: { click: function(){} } }, [
  h("span", { style: { fontWeight: "bold" } }, "This is bold"),
  " and this is just normal text",
  h("a", { props: { href: "/foo" } }, "I'll take you places!"),
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);

const newVnode = h(
  "div#container.two.classes",
  { on: { click: function(){} } },
  [
    h(
      "span",
      { style: { fontWeight: "normal", fontStyle: "italic" } },
      "This is now italic type"
    ),
    " and this is still just normal text",
    h("a", { props: { href: "/bar" } }, "I'll take you places!"),
  ]
);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state

子节点更新策略:新前与旧前、新后与旧后、新后与旧前、新前与旧后。如果以上四种命中查找方式都未命中,就需要通过循环遍历旧节点列表来查找是否存在与当前新节点相同的节点,以确定是否需要移动或替换节点。

BUG 分析

  • title优化 ✔️

针对不同打包环境展示不同内容,通过插件方式区别定义后报错如下:

Template execution failed: ReferenceError: ❌... is not defined
ReferenceError: ❌... is not defined
  - index.html:7 eval
    [.]/[cli-service]/[html-webpack-plugin]/lib/loader.js!./public/index.html:7:

<%= htmlWebpackPlugin.options.isProd ? '' : 'dev - ' %> 在注入模板字符串语法后,注释内容的不规范所导致。

  • vue-ui新建项目 ✔️

初始化项目时删除 hello.vue 后,在 app.vue 导入组件热编译运行报错:

This relative module was not found:
* ../views/About.vue in ./src/router/index.js

项目中安装 router 依赖,需要及时更新 router 中对应的 index.js 相关初始化配置,指向 app.vue

  • warning ❓

"无法解析" 的警告出现几率较为频繁,但是并不影响运行。详情移步

WARN  
Couldn't parse bundle asset "/Users/.../zsvuex/dist/js/chunk-vendors.js".
Analyzer will use module sizes from stats file.
  • echarts ✔️

使用 Echarts 可视化图表,在按照官方文档说明进行敲代码时,遇见如下报错:

[Echarts] TypeError: Cannot read property 'init' of undefined

var myChart = echarts.init(document.getElementById('main')); 定位到存在 init 报错的位置。方法出现 undefined 考虑此调用对象的声明定义是否成功。补充代码 var echarts = require('echarts');

  • import ✔️

引入 vant 组件库出现报错

error  Import in body of module; reorder to top  import/first

代码格式规范要求所有的 import 在顶部,中间不允许出现其他代码,如vue.use(vant)

  • PostCSS Error ✔️

npm install postcss-pxtorem -D 下载将 px 转化为 rem 单位的插件出现报错:

Syntax Error: Error: PostCSS plugin postcss-pxtorem requires PostCSS 8.
Migration guide for end-users:
https://github.com/postcss/postcss/wiki/PostCSS-8-for-end-users


 @ ./src/style/index.less ...
 @ ./src/main.js
 @ multi (webpack)-dev-server/client?http://192.xxx.xxx.2:8080&sockPath=/sockjs-node (webpack)/hot/dev-server.js ./src/main.js

由于 postcss-pxtorem 版本过高,放弃了对旧 Node.js 版本的支持。考虑到 PostCSS 8 未带来重大的 API 更改。先执行: npm uninstall postcss-pxtorem 卸载当前版本。在 package.json 中 devDependencies 下添加"postcss-pxtorem": "^5.1.1",执行 npm i 安装制定版本包。

  • ElementUI 按需加载后启动项目时的 babel-preset-es2015 报错。

项目使用 @vue/cli 4.5.x 脚手架工具构建,根据官方文档中的提示按需引入。babelrc 和 babel.config.js 的区别在于,前者只会影响本项目中的代码,而后者会影响整个项目中的代码,包含 node_modules。

// 出错配置
module.exports = {
  "presets": [
    '@vue/cli-plugin-babel/preset',["es2015", { "modules": false }]
  ],
  "plugins": [
    [
      "component",
      {
        "libraryName": "element-ui",
        "styleLibraryName": "theme-chalk"
      }
    ]
  ]
}
// Error
Cannot find module 'babel-preset-es2015'

修改 babel.config.js 文件,将 es2015 改为 @babel/preset-env。

// 正解配置
module.exports = {
  "presets": [
    '@vue/cli-plugin-babel/preset',["@babel/preset-env", { "modules": false }]
  ],
  "plugins": [
    [
      "component",
      {
        "libraryName": "element-ui",
        "styleLibraryName": "theme-chalk"
      }
    ]
  ]
}
  • 引入字体

将后缀名为 .ttf、.otf、.eot 等格式的字体包放入新建的 fonts 文件夹,并创建一个 fonts.css 文件用于定义所用字体。在 app.vue 中引入 fonts.css。

@font-face: {
  font-family: 'xxxyyy';
  /* 重命名字体名 */
  src: url('./xxxxxx.otf');
  font-weight: normal;
  font-style: normal;
}
<style lang="scss">
  @import './assets/fonts/fonts.css';
  #app {
    font-family: 'xxxyyy';
    font-weight: normal;
  }
</style>

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!