Vue源码阅读笔记之语法细节(四)

这是”Vue源码阅读笔记”系列第四篇文章。上一章我们分析了Vue的模板编译和渲染过程,却忽略了模板中的很多细节,比如属性绑定、事件绑定、指令和计算属性等。此外还有混合、自定义属性监听等一些细节没有处理,由于后续的分析中这些细节不可避免,因此趁热打铁,来梳理一下Vue相关的一些细节。

<!--more-->

1. 模板语法

Vue的模板语法实际上有不少,下面先简单总结一下:

  • {{exp}}形式插入数据
  • v-forv-ifv-html等指令
  • v-bind:属性绑定,缩写:
  • v-on事件监听,缩写@
  • 指令修饰符.
  • computed计算属性
  • filters过滤器,在模板中使用|进行修饰

1.1. 入门示例

然后写一个包含上面语法的简单例子来实验一下


<div id="app">
    <h1 @click.stop="changeMsg" title="this is static Title" :class="{'text-red': isWarn}" v-html="formatHtml">

      </h1>
    <ul>
        <li v-for="(item, index) in arr" v-if="item%2">{{item | double}}</li>
    </ul>
</div>
let vm = new Vue({
  el: "#app",
  data: {
    msg: "Hello World",
    arr: [1, 2, 3],
    isWarn: true
  },
  computed: {
    formatDate() {
      return new Date().toDateString();
    },
    formatHtml() {
      return `<span>${this.formatDate}</span>`;
    }
  },
  filters: {
    double(val) {
      return val * 2;
    }
  },
  methods: {
    changeMsg() {
      this.msg = "Now msg has changed";
    },
    listenChild(e) {
      console.log("msg from child");
      console.log(e);
    }
  }
});

1.2. 分析render函数

我们先来看看编译得到的render函数是什么样子的

(function() {
  with (this) {
    return _c("div", { attrs: { id: "app" } }, [
      _c("h1", {
        class: { "text-red": isWarn },
        attrs: { title: "this is static Title" },
        domProps: { innerHTML: _s(formatHtml) },
        on: {
          click: function($event) {
            $event.stopPropagation();
            changeMsg($event);
          }
        }
      }),
      _v(" "),
      _c(
        "ul",
        _l(arr, function(item, index) {
          return item % 2 ? _c("li", [_v(_s(_f("double")(item)))]) : _e();
        })
      )
    ]);
  }
});

通过render函数,我们可以先猜一猜对应的模板语法哈哈~

  • title等保留属性保留在attrs属性下,v-html指令会保留在domProps属性下,对应innerHTML
  • class会直接保留在class属性下
  • v-for会编译成_l(arr, cb)函数,v-if会生成三目运算符
  • 过滤器会编译成_f("double")(item)
  • 计算属性会编译成_s(formatHtml)
  • @等注册的事件保留在on属性下,修饰符.stop会调用stopPropagation

参考官方文档关于createElement方法的配置对象,在模板渲染这章中我们了解render函数的生成过程,以及_l_f等辅助函数的大致作用。看来接下来还是需要去看一看模板解析的内部实现,先定个小要求:不拘泥于实现细节

1.3. 模板解析的细节

在上一章我们知道了模板解析的具体工作是在baseCompile中进行的,实际工作又分为了3步

// 解析模板为AST
const ast = parse(template.trim(), options)
// 优化AST ,标记不会变化的子节点树
optimize(ast, options)
// 根据AST生成render函数
const code = generate(ast, options)

AST(抽象语法树)实际上就是将模板转换成JavaScript对象的结果。在parse中使用了大量的正则对标签和属性进行提取,然后返回根节点的AST对象。

相对于原始模板标签,JS对象更容易操作,我们先看一看AST长啥样子

  • 标签上不同的属性被解析到不同的节点上,比如指令解析在attrsList中
  • 而节点上的static属性,是在第二步添加的。

而对于第三步,具体一点来说就是根据AST语法树的节点属性,拼接执行函数。而我们要现在要了解的就是generate,主要是看看模板中的指令在内部的实现。

// /src/compler/codegen/index.js
export function generate (
  ast: ASTElement | void,
  options: CompilerOptions
): CodegenResult {
  // 根据options生成对应的CodegenState对象,该对象只有一些属性用来保存编译过程中的数据
  const state = new CodegenState(options)
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

1.4. generate函数

调用genElement,传入根节点的ast,然后生成对应的code

// 通过ast和state生成code
export function genElement (el: ASTElement, state: CodegenState): string {
  if (el.staticRoot && !el.staticProcessed) {
    return genStatic(el, state)
  } else if (el.once && !el.onceProcessed) {
    return genOnce(el, state)
  } else if (el.for && !el.forProcessed) {
    return genFor(el, state)
  } else if (el.if && !el.ifProcessed) {
    return genIf(el, state)
  } else if (el.tag === 'template' && !el.slotTarget) {
    return genChildren(el, state) || 'void 0'
  } else if (el.tag === 'slot') {
    return genSlot(el, state)
  } else {
    // component or element
    let code
    if (el.component) {
      code = genComponent(el.component, el, state)
    } else {
      // 处理ast相关数据属性
      const data = el.plain ? undefined : genData(el, state)
      // 处理子节点    
      const children = el.inlineTemplate ? null : genChildren(el, state, true)
      code = `_c('${el.tag}'${
        data ? `,${data}` : '' // data
      }${
        children ? `,${children}` : '' // children
      })`
    }
    // module transforms
    for (let i = 0; i < state.transforms.length; i++) {
      code = state.transforms[i](el, code)
    }
    return code
  }
}
// 生成子节点

genData展示了如何将ast节点的属性值转换为createElement函数的配置项,这个过程貌似跟写编译器很像~

export function genData (el: ASTElement, state: CodegenState): string {
  let data = '{'

  // directives first.
  // directives may mutate the el's other properties before they are generated.
  const dirs = genDirectives(el, state)
  if (dirs) data += dirs + ','

  // key
  if (el.key) {
    data += `key:${el.key},`
  }
  // ref
  if (el.ref) {
    data += `ref:${el.ref},`
  }
  if (el.refInFor) {
    data += `refInFor:true,`
  }
  // pre
  if (el.pre) {
    data += `pre:true,`
  }
  // record original tag name for components using "is" attribute
  if (el.component) {
    data += `tag:"${el.tag}",`
  }
  // module data generation functions
  for (let i = 0; i < state.dataGenFns.length; i++) {
    data += state.dataGenFns[i](el)
  }
  // attributes
  if (el.attrs) {
    data += `attrs:{${genProps(el.attrs)}},`
  }
  // DOM props
  if (el.props) {
    data += `domProps:{${genProps(el.props)}},`
  }
  // event handlers
  if (el.events) {
    data += `${genHandlers(el.events, false, state.warn)},`
  }
  if (el.nativeEvents) {
    data += `${genHandlers(el.nativeEvents, true, state.warn)},`
  }
  // slot target
  // only for non-scoped slots
  if (el.slotTarget && !el.slotScope) {
    data += `slot:${el.slotTarget},`
  }
  // scoped slots
  if (el.scopedSlots) {
    data += `${genScopedSlots(el.scopedSlots, state)},`
  }
  // component v-model
  if (el.model) {
    data += `model:{value:${
      el.model.value
    },callback:${
      el.model.callback
    },expression:${
      el.model.expression
    }},`
  }
  // inline-template
  if (el.inlineTemplate) {
    const inlineTemplate = genInlineTemplate(el, state)
    if (inlineTemplate) {
      data += `${inlineTemplate},`
    }
  }
  data = data.replace(/,$/, '') + '}'
  // v-bind data wrap
  if (el.wrapData) {
    data = el.wrapData(data)
  }
  // v-on data wrap
  if (el.wrapListeners) {
    data = el.wrapListeners(data)
  }
  return data
}

上面展示了最主要的执行流程 ,从genElement生成根节点开始

  • genData中,通过当前ast节点参数(来自于模板解析时解析标签的属性),拼接createElement函数的配置项参数
  • 调用genChildren生成子节点,而在genChildren中又会递归调用genElement从而生成虚拟DOM树。
  • genElement会根据当前标签属性的特性,生成特定的代码片段(就是函数内部前面那一段判断)

理解了genDatagenChildren,实际上对于整个模板解析的细节就差不多了~通过拼接代码,最后一个with(this)绑定到当前vm实例,然后执行编译函数就可以了。那么接下来的问题就是:编译函数中访问的属性和方法是如何绑定到vm上的呢?

实际上,在第二章分析了initData实现响应式数据,实际上vm实例还包括了其他的一些状态,比如computedwatch等,我们来简单看看他们的实现。

2. 其他的state

跟data参数一样,其他的状态大致的流程也是:配置参数->$options->initState()进行的。

2.1. computed

计算属性可以避免在模板中放入太多的逻辑。对于任何复杂逻辑,你都应当使用计算属性

// /src/core/instance/state.js
function initComputed (vm: Component, computed: Object) {
  const watchers = vm._computedWatchers = Object.create(null)

  for (const key in computed) {
    const userDef = computed[key]
    // 默认computed属性定义为函数则只有getter
    // 但是可以通过get和get对象提供setter和getter
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    // 实例化一个新的wathcer
    watchers[key] = new Watcher(
      vm,
      getter || noop,
      noop,
      computedWatcherOptions
    )

    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    }
  }
}

计算属性是会被缓存的,缓存的意思是:只有在计算属性的的相关依赖发生改变时才会重新求值。比如前面的demo中,

computed: {
  formatHtml(){
      return `<p>${this.formatDate}</p>`
  }
}

由于在访问this.formatDate时会在formatDate的get中收集依赖,因此当其发生变化时,对应的计算属性也会发生变化。

但多次访问同一个计算属性时,其结果是会被缓存的,这也是computedmethods的不同,下面是具体的实现

export function defineComputed (
  target: any,
  key: string,
  userDef: Object | Function
) {
  const shouldCache = !isServerRendering()
  if (typeof userDef === 'function') {
    // 修改get,防止重复计算
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : userDef
    sharedPropertyDefinition.set = noop
  } else {
    sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : userDef.get
      : noop
    sharedPropertyDefinition.set = userDef.set
      ? userDef.set
      : noop
  }
  // 最后直接上计算属性的key绑定到当前vm对象属性上
  // 而不是通过代理的方式访问,这也是跟data不同的地方
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      // 实际上返回的watcher的value值
      return watcher.value
    }
  }
}

这里居然发现了一个cache的配置,意思是可以强制不缓存计算属性值而是每次都调用get。表示教程文档里面居然没有看见这个选项~看源码的意外收获

2.2. watch

Vue 通过 watch 选项提供了一个更通用的方法,实现自定义的侦听器,来响应数据的变化。

// /src/core/instance/state.js
function initWatch (vm: Component, watch: Object) {
  // 遍历watch属性,依次注册
  for (const key in watch) {
    const handler = watch[key]
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      createWatcher(vm, key, handler)
    }
  }
}
function createWatcher (
  vm: Component,
  keyOrFn: string | Function,
  handler: any,
  options?: Object
) {
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  return vm.$watch(keyOrFn, handler, options)
}

在前面的学习中我们知道vm.$watch是在stateMixin(Vue)中注册的。

// /src/core/instance/state.js
Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: any,
    options?: Object
  ): Function {
    const vm: Component = this
    if (isPlainObject(cb)) {
      return createWatcher(vm, expOrFn, cb, options)
    }
    options = options || {}
    options.user = true
      // 实例化一个watcher对象
    const watcher = new Watcher(vm, expOrFn, cb, options)
    if (options.immediate) {
      cb.call(vm, watcher.value)
    }
    return function unwatchFn () {
      watcher.teardown()
    }
  }

可以看见,watch配置实际上是提供了为指定属性额外注册Watcher的接口,当对应属性发生变化时,会通知所有的watcher,在watcher内部的run方法中可以找到对应的回调执行

this.cb.call(this.vm, value, oldValue)

此时新值和旧值都会被当做参数传入回调然后运行。

2.3. methods

方法是经常用到的一个配置项,其注册也十分简单,在内部通过bind直接绑定到当前vm实例上

// /src/core/instance/state.js
function initMethods (vm: Component, methods: Object) {
  const props = vm.$options.props
  for (const key in methods) {
    // ... 省略判断
    vm[key] = methods[key] == null ? noop : bind(methods[key], vm)
  }
}

然后就可以直接通过this.methodName进行访问了

2.4. props

属性是组件系统一个非常重要的概念,在下一章我们会专门分析props,因此这里暂时略过~

3. 资源

3.1. filters

Vue.js 允许你自定义过滤器,可被用于一些常见的文本格式化

过滤器这个概念在很多其他的模板引擎中也是存在的,比如swig。可以把他当做是辅助函数的语法糖,比如格式化日期、添加图片前缀的功能,可以通过过滤器来实现,减少模板中的逻辑,从而更方便维护。

从上面的-f()函数入手

export function resolveFilter (id: string): Function {
  return resolveAsset(this.$options, 'filters', id, true) || identity
}

resolveFilter实际上只是resolveAsset的快捷方式

export function resolveAsset (
  options: Object,
  type: string,
  id: string,
  warnMissing?: boolean
): any {
  /* istanbul ignore if */
  if (typeof id !== 'string') {
    return
  }
  // 提取vm.$options上对应的资源项
  const assets = options[type]
  // check local registration variations first
  if (hasOwn(assets, id)) return assets[id]
  const camelizedId = camelize(id)
  if (hasOwn(assets, camelizedId)) return assets[camelizedId]
  const PascalCaseId = capitalize(camelizedId)
  if (hasOwn(assets, PascalCaseId)) return assets[PascalCaseId]
  // fallback to prototype chain
  const res = assets[id] || assets[camelizedId] || assets[PascalCaseId]
  return res
}

过滤器可以是局部注册,也可以通过Vue.filter全局注册。实际上全局注册其内部也只是在实例上添加了对应的属性而已。

// /src/core/global-api/assets.js
this.options[type + 's'][id] = definition

我觉得只需要把过滤器理解为一个针对模板数据的辅助函数即可。而过滤器的参数顺序和串联使用,反倒是更应该掌握的地方~

3.2. directive

除了核心功能默认内置的指令 (v-modelv-show),Vue 也允许注册自定义指令。如果需要对普通 DOM 元素进行底层操作,这时候就会用到自定义指令

尽管MVVM框架中大部分时候是通过数据去驱动视图的,但是在某些时候还是需要我们直接操作DOM节点,自定义指令这时候就非常有用了。下面我们来看看源码中是如何处理内置指令和自定义指令的。

我们可以在render函数的第二个参数中找到directives相关的配置,在前面生成render函数的的genData中,可以看见首先处理的就是genDirectives

function genDirectives (el: ASTElement, state: CodegenState): string | void {
  const dirs = el.directives
  if (!dirs) return
  let res = 'directives:['
  let hasRuntime = false
  let i, l, dir, needRuntime
  for (i = 0, l = dirs.length; i < l; i++) {
    dir = dirs[i]
    needRuntime = true
    const gen: DirectiveFunction = state.directives[dir.name]
    if (gen) {
      // v-html等内置的指令会在这里直接执行
      needRuntime = !!gen(el, dir, state.warn)
    }
    if (needRuntime) {
      // 其他的会拼接成render函数的配置项
      hasRuntime = true
      res += `{name:"${dir.name}",rawName:"${dir.rawName}"${
        dir.value ? `,value:(${dir.value}),expression:${JSON.stringify(dir.value)}` : ''
      }${
        dir.arg ? `,arg:"${dir.arg}"` : ''
      }${
        dir.modifiers ? `,modifiers:${JSON.stringify(dir.modifiers)}` : ''
      }},`
    }
  }
  if (hasRuntime) {
    return res.slice(0, -1) + ']'
  }
}

内置的指令

从代码中可以发现,内置的指令会在编译时立即执行。其中,state.directives来自于其构造函数中

export class CodegenState {
  constructor (options: CompilerOptions) {
    // ...

    // 这里合并baseDirectives和配置项的directives
    // baseDirectives包括v-on,v-bind和v-cloak
    // options.directives包括v-model,v-text和v-html
    this.directives = extend(extend({}, baseDirectives), options.directives)
  }
}

我们可以从generate(ast, options)的options来源找到options.directives的出处。

// /src/platforms/web/comiler/index.js 
const { compile, compileToFunctions } = createCompiler(baseOptions)

说明我们可以为不同的平台声明不同的自定义指令,这在后面了解SSR可能会有帮助哦~先挖个坑

自定义指令

与内置指令不同的是,自定义指令作为配置参数在createElement中执行,我们去一探究竟

vm._c出发,找到_createElement方法

// /src/core/vdom/create-element.js
export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode {
  let vnode = new VNode(
      tag, data, children,
      undefined, undefined, context
  )
  return vnode;
}

发现只是将配置参数传入了VNode的构造函数中,那么只剩下patch了(转念一想实际上也是对的,因为执行相关的钩子函数是在构建DOM树的时候才触发的)

insert钩子函数为例

// /src/core/vdom/patch.js
function invokeInsertHook (vnode, queue, initial) {
  if (isTrue(initial) && isDef(vnode.parent)) {
    vnode.parent.data.pendingInsert = queue
  } else {
    for (let i = 0; i < queue.length; ++i) {
      queue[i].data.hook.insert(queue[i])
    }
  }
}

export function createPatchFunction (backend) {
      return function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
    // 依次调用insertedVnodeQueue队列中每个vnode的insert钩子,此时就是执行对应指令的钩子函数
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)  
  }
}

自定义指令还有其他一些钩子函数,除了insert之外,常用的还有update等。

大家可能对于vnode.data.hook的来源有一些疑惑,实际上是通过invokeCreateHooks添加的

  function invokeCreateHooks (vnode, insertedVnodeQueue) {
    for (let i = 0; i < cbs.create.length; ++i) {
      cbs.create[i](emptyNode, vnode)
    }
    i = vnode.data.hook
    if (isDef(i)) {
      if (isDef(i.create)) i.create(emptyNode, vnode)
      if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
    }
  }

cbs.create有一个updateDirectives的钩子函数,其内部调用_update函数对新指令进行注册,对已存在指令进行更新

3.3. component

组件系统跟前面的props相呼应,我们下一章见~

4. 事件

回头看看模板语法,我们现在貌似还剩下了事件绑定。接下来让我们来看看Vue中的事件系统。

自定义事件

eventsMixin(Vue)函数中,为Vue原型添加了相关事件的接口,包括$on$once$emit$off

// /src/core/instance/events.js
export function eventsMixin (Vue: Class<Component>) {
      // 注册事件处理函数
  Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
      const vm: Component = this
      // 实例内部维护一个_events对象,属性即为事件名,对应的属性值为一个维护处理函数的数组
      (vm._events[event] || (vm._events[event] = [])).push(fn)
    }
    return vm
  }
      // 注册只执行一次的事件处理函数
  Vue.prototype.$once = function (event: string, fn: Function): Component {
    const vm: Component = this
    // 使用on()函数替代默认的事件处理函数,
    // 在函数内部先取消对应处理函数,再执行该处理函数
    function on () {
      vm.$off(event, on)
      fn.apply(vm, arguments)
    }
    on.fn = fn
    vm.$on(event, on)
    return vm
  }
  // 取消事件处理函数
  Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {
    const vm: Component = this
    // 取消事件的本质实际上就是修改对应事件键值所维护的处理函数队列
    // 根据参数形式有取消全部事件及其处理函数、取消多个事件的处理函数、取消特定事件的处理函数、取消特性的处理函数等处理,这里就不粘贴代码了
    // ...
    return vm
  }
  // 触发自定义事件
  Vue.prototype.$emit = function (event: string): Component {
    const vm: Component = this
    // 获取对应事件的所有处理函数,然后依次执行,即相当于触发了对应的事件
    let cbs = vm._events[event]
    if (cbs) {
      cbs = cbs.length > 1 ? toArray(cbs) : cbs
      // 获取额外的参数并作为事件处理函数的参数
      const args = toArray(arguments, 1)
      for (let i = 0, l = cbs.length; i < l; i++) {
          cbs[i].apply(vm, args)
      }
    }
    return vm
  }
}

这几个接口是组件通信的基础,同样会在下一章进行讲解。实际上事件系统跟发布-订阅者模式十分相似,理解起来也有相同之处。

原生事件

但是上面的接口跟我们在模板中通过@on或者v-on注册的事件好像不一样。来个简单的例子

<input v-focus @blur.stop="blur">

有了前面的经验,我们直接从render函数入手然后去研究对应vnode在Patch过程中的处理即可

_c("input", {
  directives: [{ name: "focus", rawName: "v-focus" }],
  on: {
      blur: function($event) {
        $event.stopPropagation();
        blur($event);
    }
  }
})

可以render函数的生成过程中会直接将事件修饰符转换成对应的代码片段,而事件则会注册在on属性上,跟着断点我们可以找到,将on属性的每个事件的处理函数注册到对应的DOM元素这个过程是在invokeCreateHooks这里进行的

function invokeCreateHooks (vnode, insertedVnodeQueue) {
    for (let i = 0; i < cbs.create.length; ++i) {
      // cbs.create[2]就是updateDOMListeners
      cbs.create[i](emptyNode, vnode)
    }
}
// /src/platforms/web/runtime/modules/events.js
function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) {
  if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
    return
  }
  const on = vnode.data.on || {}
  const oldOn = oldVnode.data.on || {}
  target = vnode.elm
  normalizeEvents(on)
  // 内部调用add方法会调用vnode.elm.addEventListener来注册事件处理函数
  updateListeners(on, oldOn, add, remove, vnode.context)
  target = undefined
}

OK,现在我们算是知道了原生事件是如何绑定在DOM元素上的了。实际上这里的cbs有7个初始化方法,均会在invokeCreateHooks时调用,用来在vnode生成真实DOM节点时进行初始化。

5. 小结

这篇文章粗略整理了不少东西,主要是分析了Vue构造函数配置项中的相关选项及其内部的流程(当然还是忽略了具体的实现细节)。其中,propscomponents和事件的深入了解,我把它放到下一章_组件系统_中。

这篇文章本身就是在分析组件系统时回头重新整理的,大概花了一周的时间回头一看,怎么这么长了(虽然大部分都是复制的源码哈哈)