Vue 源码阅读笔记

注: 路人读者请移步 => Huang Yi 老师的Vue源码阅读点这里, 我写这一篇笔记更倾向于以自问自答的形式增加一些自己的理解 , 内容包含面试题范围但超出更多.

自己提出的问题自己解决:

  1. core/vdom/patch.js setScope如何做到// set scope id attribute for scoped CSS.?
    目前看到了它调用了nodeOps.setStyleScope(vnode.elm, i),即vnode.elm.setAttribute(i, ' ')

1 Vue.util

Vue.util.extend这个函数为例, 查找顺序为:

  • src/platforms/web/entry-runtime-with-compiler.js
    import Vue from './runtime/index'
    • src/platforms/web/runtime/index.js
      import Vue from 'core/index'
      import Vue from './instance/index'
      initGlobalAPI(Vue) from import { initGlobalAPI } from './global-api/index'
      Vue.util = { warn, extend, mergeOptions, defineReactive } from
      import { warn, extend, nextTick, mergeOptions, defineReactive } from '../util/index'
      export * from 'shared/util'
// in shared/util
/**
 * Mix properties into target object.
 */
export function extend (to: Object, _from: ?Object): Object {
  for (const key in _from) {
    to[key] = _from[key]
  }
  return to
}

可以看出这个 extend 函数只支持2个参数, 这也印证了源码中提到的"不要依赖 Vue 的 util 函数因为不稳定" , 实测:

2 Vue 数据绑定

//调用例子: proxy(vm,`_data`,"message")
export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

实现的效果就是访问this.message实际上通过get访问到了vm._data.message,而访问this.message = 'msg'则是通过set访问了this.message.set('msg')this._data.message = 'msg'
而初始值是在initMixininitData方法中通过

data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}

来赋予初值

2.2 vm.$mount

入口: initMixin中结尾的时候

if (vm.$options.el) {
      vm.$mount(vm.$options.el) 
}

取得 el 对应的 dom

el = el && query(el)
//&& 和 || :
//进行布尔值的且和或的运算。当运算到某一个变量就得出最终结果之后,就返回哪个变量。所以上式能返回 dom.
//再举个例子: 1 && undefined === undefined , 1 && {1:2} === {1:2}
为什么 Vue 不允许挂载在html | body上?

因为它是替换对应的节点,如果 html 或 body 被替换的话整个文档就出错了

template

$options 中有 template 的话

  • 优先选用<template></>innerHTML来作为模板,
  • 或者选用template:#id对应的query('#id')innerHTML,
  • 最后才是会选用getOuterHTML(el)来作为模板

最后,创建好render函数并挂载到vm上等待执行.

2.3 vm._render link

Vue 的 _render 方法是实例的一个私有方法,它用来把实例渲染成一个虚拟 Node。它的定义在 src/core/instance/render.js 文件中

2.3.1 ES6 Proxy link

2.4 Virtual DOM

Virtual DOM 是简化的 DOM 形式的结构, 以下面例子为例

<body>
 <div id="app">
   <span>{{message}}</span>
 </div>

</body>
<script src="./vue.js"></script>
<script>
 var app = new Vue({
   el: "#app",
   data() {
     return {
       message: 'test'
     }
   }
 })
</script>

断点位置:执行 mountComponent时,vm._update(vm._render(),hydrating)这里, render函数会返回如下的 VNODE (简化版):

{
  tag:"div",
  children:{
    tag:"span",
    children:{
      tag:undefined,
      text:"test"
    }
  }
}

Vnode 其它属性中, 原 dom 的 attr 存在了 data 中, 还有更多的属性不逐个列举了

data : {
  attrs: {data-brackets-id: "149", id: "app", editable: ""}
  class: "test"
  staticClass: "origin"
  staticStyle: {color: "red"}
  __proto__: Object
}
提问: 从app的哪个属性可以访问到vnode?

答: app.$vnode不可以, 但是app._vnode可以.
一般情况下的约定中, 以$开头的属性名是 Vue 暴露出来的接口(例如this.$store.state), _开头的是私有属性不建议访问, 而普通的(例如app.message)则是从 data 代理过来的数据.

2.4.1 children 规范化(normalize)

_createElement 接收的第 4 个参数 children 是任意类型的,因此我们需要把它们规范成 VNode 类型。

正常的由 template 得到的 VNode 是不需要序列化的, 触发序列化的有如下几种情况:

  1. functional component 函数式组件返回的是一个数组而不是一个根节点时, 会调用simpleNormalizeChildren, 通过 Array.prototype.concat 方法把整个 children 数组打平,让它的深度只有一层
  2. render 函数是用户手写时,当 children 由基础类型组成时,Vue会调用 normalizeChildren中的createTextVNode 创建一个文本节点的 VNode;
  3. 当编译 slot、v-for 的时候会产生嵌套数组的情况(测试发现 template中有简单嵌套v-for 的时候并不触发该条规则,可能强制要求手写 render 或 复杂component),会调用 normalizeArrayChildren 方法, 递归 调用自己来处理 Vnode , 同时如果遇到VList则用nestedIndex维护一下它的key

学习一下手写 render 函数: link

Vue.component('anchored-heading', {
  render: function(h) {
    return h('h' + this.level, this.$slots.default )
  },
  props: {
    level: {
      type: Number,
      required: true
    }
  }
})

2.5 vm._update(vnode:VNode,hydrating?:boolean)

_update 方法的作用是把 VNode 渲染成真实的 DOM,hydrating表示是否是服务端渲染 , 它的定义在 src/core/instance/lifecycle.js

vm.$el = vm.__patch__(
        vm.$el, vnode, hydrating, false /* removeOnly */,
        vm.$options._parentElm,
        vm.$options._refElm
      )

调用__patch__来xx

//可以看出__patch__是用来用第二个参数去替换第一个参数, 
//而第一个参数可以是旧的 Vnode 也可以是原生 DOM
vm.$el = vm.__patch__(prevVnode, vnode)

__patch__的定义查找顺序,platform/web/runtime/index.js => patch.js => 取出后端的 node-ops.js 中各种方法,传递给 cor/vdom/patch 的 createPatchFunction()

第一次 update 时return function patch中的关键语句就是:
oldVnode = emptyNodeAt(oldVnode)


{ 该花括号用于指示分析文字的作用域

先创建一个空的根节点 Vnode(只有 tag的那种)
createElm(...)
根据 Vnode 创建实际的 DOM 并插入到原 DOM. 其中递归调用了createChildren

createChildren 我get到的理解

createChildren(vnode, children, insertedVnodeQueue)

function createChildren (vnode, children, insertedVnodeQueue) {
  if (Array.isArray(children)) {
    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(children)
    }
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
    }
  } else if (isPrimitive(vnode.text)) {
    nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
  }
}

在这里要注意如果原 template 中一个节点只有1个子节点, 那么该 vnode 的 children 属性将为一个长度为1的 Array, 所以仍会进入第一个 if 分支. 也就是说children 要么为一个长度至少为1的 Array,要么就是 undefined
createChildren执行完之后 this._vnode.elm 就是构建完成的原生 DOM 了,接下来执行insert(parentElm, vnode.elm, refElm);来把它插入到合适的位置(此时还未删除原节点,如下图)

insert(parentElm, vnode.elm, refElm); 的结果

} 至此 createElm 终于结束了

                              // destroy old node
                              if (isDef(parentElm$1)) {
                                removeVnodes(parentElm$1, [oldVnode], 0, 0);
                              } else if (isDef(oldVnode.tag)) {
                                invokeDestroyHook(oldVnode);
                              }
                            }
                          }

                          invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
                          return vnode.elm

__patch__的最后, 根据之前保存的parentElm$1(在例子中是 body)和oldVnode来移除之前的原生 DOM 节点, 调用invokeInsertHook(毕竟它插入新节点和删除旧节点都完成了嘛,是时候向上级报告啦!),至此__patch__全部完成, 返回值用于更新vm.$el,
接下来做了约10行的收尾工作(这一章不涉及组件的话vm.$vnode = undefined也看不出什么来)
至此, _update函数全部完成!

2.6 第二章数据绑定总结:

3 组件化

3.1 createComponent

这一小节主要讲了把一个组件构建为 vnode 的过程

测试时使用的例子:

<body>
  <div id="app" class="origin" :class="message" style="color:red" editable ref="a1">
    <span>{{message}}</span>
    <cc></cc>
  </div>
</body>
<script src="./vue.js"></script>
<script>
  Vue.component('cc',{
    template:'<strong>I am component</strong>'
  })
  var app = new Vue({
    el: "#app",
    data() {
      return {
        message: 'test'
      }
    }
  })
</script>

小细节: 组件在创建 Vnode 时, children, text, elm为空,但componentOptions属性包含了所需要的内容

const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )

最终我们的例子的组件会返回如下 vnode(不写的属性默认为 undefined):

vnode:{
  tag: "vue-component-1-cc",
  test: undefined,
  children: undefined,
  data: {
    attrs: { },
    hook: { destroy, init, insert ,prepatch, on }
  },
  context: Vue,
  componentOptions: {
    Ctor:{  
      extendOptions: { name:"cc",template:"<strong>I am component</strong>"},
      options:{ components, _Ctor, _base,  name:"cc",template:"<strong>I am component</strong>"}
    },
    tag: "cc"
  }
}

3.1 patch - 从组件 vnode 构建组件 DOM

了解 patch 的整体流程和插入顺序

  • activeInstance
  • $vnode
  • _vnode
  • patch的整体流程: createComponent => 子组件初始化 => 子组件 render => 子组件 patch
  • activeInstance为当前激活的 vm 实例; vm.$vnode为组件的占位符 vnode ; vm._vnode为组件的渲染 vnode

3.2 mergeOptions 合并配置

入口core/instance/index.js其中//...是暂时略去无关代码的意思

function Vue (options) {
  //...
  this._init(options)
}

接着来到core/instance/init.js

export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    //...
    // merge options
    if (options && options._isComponent) {
      // optimize internal component instantiation
      // since dynamic options merging is pretty slow, and none of the
      // internal component options needs special treatment.
      initInternalComponent(vm, options)
    } else {
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
    //...
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

执行new Vue(options)resolveConstructorOptions返回的就是大Vue本身, 接着继续看mergeOptions(在src/core/util/options.js里)

/**
 * Merge two option objects into a new one.
 * Core utility used in both instantiation and inheritance.
 */
export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {
  //...
  const options = {}
  let key
  for (key in parent) {
    mergeField(key)
  }
  for (key in child) {
    if (!hasOwn(parent, key)) {
      mergeField(key)
    }
  }
  function mergeField (key) {
    const strat = strats[key] || defaultStrat
    options[key] = strat(parent[key], child[key], vm, key)
  }
  return options
}

3.2.1 默认策略

const defaultStrat = function (parentVal: any, childVal: any): any {
  return childVal === undefined ? parentVal : childVal
}

3.2.2 strats.el

使用默认策略(要求 vm 必须实例化)

3.3.3 strats.data

若 vm 为空则要求传入的 data必须是个函数, 然后返回mergeDataOrFn. 简言之, 就是尝试去获取 ___Val.call(vm,vm)或 Val 本身(取决于它是不是函数)来得到数据, 然后调用一个无依赖的函数mergeData进行深拷贝形式的 merge

export function mergeDataOrFn (
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    //...
  } else {
    return function mergedInstanceDataFn () {
      // instance merge
      const instanceData = typeof childVal === 'function'
        ? childVal.call(vm, vm)
        : childVal
      const defaultData = typeof parentVal === 'function'
        ? parentVal.call(vm, vm)
        : parentVal
      if (instanceData) {
        return mergeData(instanceData, defaultData)
      } else {
        return defaultData
      }
    }
  }
}

3.3.4 props methods inject computed

先断言 childVal 是 Object , 然后使用简单的 extend函数将二者合并

strats.props =
strats.methods =
strats.inject =
strats.computed = function (
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): ?Object {
  if (childVal && process.env.NODE_ENV !== 'production') {
    assertObjectType(key, childVal, vm)
  }
  if (!parentVal) return childVal
  const ret = Object.create(null)
  extend(ret, parentVal)
  if (childVal) extend(ret, childVal)
  return ret
}

3.3.5 生命周期钩子, 例如 created

主要有这些钩子:

export const LIFECYCLE_HOOKS = [
  'beforeCreate',
  'created',
  'beforeMount',
  'mounted',
  'beforeUpdate',
  'updated',
  'beforeDestroy',
  'destroyed',
  'activated',
  'deactivated',
  'errorCaptured'
]

生命周期钩子的合并策略 strats[hook]都被赋值为 mergeHook, 具体过程是把不同的 created 函数串成一串(即存入一个 array 中), 形式是[created1,created2 ,... ]

function mergeHook (
  parentVal: ?Array<Function>,
  childVal: ?Function | ?Array<Function>
): ?Array<Function> {
  return childVal
    ? parentVal
      ? parentVal.concat(childVal)
      : Array.isArray(childVal)
        ? childVal
        : [childVal]
    : parentVal
}

LIFECYCLE_HOOKS.forEach(hook => {
  strats[hook] = mergeHook
})

3.3.6 ASSET , 例如components

在这里, parent 的KeepAlive Transition TransitionGroup被传入的 child 的 components 所替代

export const ASSET_TYPES = [
  'component',
  'directive',
  'filter'
]
function mergeAssets (
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): Object {
  const res = Object.create(parentVal || null)
  if (childVal) {
    process.env.NODE_ENV !== 'production' && assertObjectType(key, childVal, vm)
    return extend(res, childVal)
  } else {
    return res
  }
}

ASSET_TYPES.forEach(function (type) {
  strats[type + 's'] = mergeAssets
})

3.4 生命周期

callHook调用不同钩子时, 我们的 vm 对象都有哪些参数呢?

3.4.1 beforeCreated

这个时候已经执行了Init Events & Lifecycle & render , 可以看到 vm 对象的$options已经执行完毕合并配置. 但这时 $data为空

3.4.1.1 未执行任意一个 init 时

可以看到已经执行过了mergeOption合并配置

3.4.1.2 执行完initLifecycle

多了$children, $parent, $refs, $root

3.4.1.3 执行完initEvents

多了_events , _hasHookEvent

3.4.1.4 执行完initRender

多了_vnode, _staticTrees, $slots, $scopedSlots, $_c, $createElement, 还未真的执行渲染. 至此, vm 这个对象已经初始化完成, 调用beforeCreated的 hook.

3.4.2 created

执行了

  • initInjections, 暂不明
  • initState 把 props data methods computed watch 挂载上了,可以访问 this.message 了 , 但此时不能访问 this.el
  • initProvide, 暂不明

3.4.3 beforeMount

入口是vm.$mount(vm.$options.el) 可以看到此时 el 进入了我们的视野,可以访问 vm.el 了, 此时的el 是一个原生HTMLElement
根据 el 来生成 render 函数
在控制台中看不到 vm._render, 但是可以执行 vm._render(), 应该和 vm._renderProxy 有关


beforeMount 钩子的执行顺序先父后子

3.4.4 mounted

子组件的 mounted 优先于父组件.

vm._update(vm._render(), ...)

先执行_render(), 再把它更新到 DOM 上. 如果检测父 vnode(vm.$vnode)为空,说明自己就是 root , 则可以调用 mount 的 hook.
后续进入等待状态, 等待数据更新带来的beforeUpdate/ update

3.4.5 beforeDestroy destroy

前者先父后子, 后者先子后父, 同 mount 类似

3.4.6 总结

created钩子中可以访问到数据, 在mounted钩子中可以访问到 DOM, 在destroyed钩子中可以做一些定时器的销毁工作.

3.5 组件注册

3.5.1 全局注册

推荐用-分割组件名
全局注册的行为必须在根 Vue 实例 (通过 new Vue) 创建之前发生

Vue.component('component-a', { /* ... */ })
Vue.component('component-b', { /* ... */ })
Vue.component('component-c', { /* ... */ })

new Vue({ el: '#app' })
//index.html
<div id="app">
  <component-a></component-a>
  <component-b></component-b>
  <component-c></component-c>
</div>

全局注册的组件在各自内部也都可以相互使用

3.5.2 局部注册

全局注册往往是不够理想的。比如,如果你使用一个像 webpack 这样的构建系统,全局注册所有的组件意味着即便你已经不再使用一个组件了,它仍然会被包含在你最终的构建结果中。这造成了用户下载的 JavaScript 的无谓的增加
局部注册的方法有:
1.js 形式

new Vue({
  el: '#app',
  components: {
    'component-a': ComponentA,
    'component-b': ComponentB
  }
})
  1. 模块系统中
import ComponentA from './ComponentA'

export default {
  components: {
    ComponentA,
  },
  // ...
}

而特别常用的局部组件应该做全局化处理, 参考官方文档

3.5.2 全局注册的源码

src/core/global-api/assets.js

export function initAssetRegisters (Vue: GlobalAPI) {
  ASSET_TYPES.forEach(type => {
    Vue[type] = function (
      id: string,
      definition: Function | Object
    ): Function | Object | void {
      if (!definition) {
        return this.options[type + 's'][id]
      } else {
        //... 组件名校验 √
        if (type === 'component' && isPlainObject(definition)) {
          definition.name = definition.name || id
          definition = this.options._base.extend(definition)
        }
        if (type === 'directive' && typeof definition === 'function') {
          definition = { bind: definition, update: definition }
        }
        this.options[type + 's'][id] = definition
        return definition
      }
    }
  })
}

核心this.options[type + 's'][id] = this.options._base.extend(definition)
即扩展了 this.$options.components.cc ( cc 只是一个组件名 )

提问: 为什么这样就可以用了呢?在模板中遇到<cc></cc>会如何解析呢?
答: 原来在_createElement的时候会对 tagName 进行判断, 如果是原生 tagName 就创建原生DOM 对应的 vnode , 否则执行vnode = createComponent(...)

过程细节:

  • resolveAssets 拼了老命地尝试去查找组件名, 先找分割线,不行就驼峰, 再不行就首字母大写试试, 还不行就去prototype 中找(这里面有 KeepAlive,Transition,TransitionGroup), 实在找不到就返回 undefined( 进而在下面判断 Array.isArray(vnode)的时候进入分支createEmptyVNode()
  • createComponent 之前的注册相当于只是记录了 component 的信息, 到这一步才是真的创建, 在这一步里, 会new VNode , 然后处理好它的 data, elm, tag等等, 同时还对 slot 做了处理

提问: vm.$options.components 什么时候拿到的呢?
答: 在合并配置时, mergeField 时, 就把 Vue['components'] 和传入参数 merge 在一起赋值给 vm.$options 了

3.5.4 局部注册的源码

过程同全局注册类似, mergeOption挂到 vm.$options.components , 这样 resolveAssets 就可以拿到了.
要注意此时并没有注册到 Vue.components 对象上

3.5.5 异步组件

还没看,暂时略过

4 深入响应式原理

4.0 什么时候收集依赖和清空依赖

收集依赖:
执行数据的 getter 时会收集依赖, 一般为[初次渲染 DOM, 执行计算属性的 getter] 等情况, 前者将 RederWatcher 添加入数据的__ob__deps[]中. 后者不仅将用户 computed watcher 添加入数据的deps[]中, 还通过computed watcher.depend()来将 RenderWatcher 添加入数据的deps[]

清空依赖 watcher.cleanupDeps()

  • RenderWatcher 执行完自己的 getter(内部执行_update(_render()), 返回销毁函数), 会执行一次清空依赖
  • 计算属性在执行完 getter 以及popTarget()后, 会执行一次清空依赖.
    • 清空前可能是name属性依赖[ useless, firstName, lastName ], 此时存放在name watchernewDepsnewDepIds中, 同时三个 data 的__ob__.deps : []中也存储了name watcher
    • 清空的过程就是查找 watcher.dep[](oldDep)中有但是newDep[]中没有的对象, 即可以想象为

name 对 firstName 说: "以前我依赖你, 但我重新审视了一下自身, 我现在已经不依赖你了, 我们断绝关系吧!"

/**
 * Clean up for dependency collection.
 */
Watcher.prototype.cleanupDeps = function cleanupDeps () {
    var this$1 = this;

  var i = this.deps.length;
  while (i--) {
    var dep = this$1.deps[i];
    if (!this$1.newDepIds.has(dep.id)) {
      dep.removeSub(this$1);
    }
  }
  var tmp = this.depIds;
  this.depIds = this.newDepIds;
  this.newDepIds = tmp;
  this.newDepIds.clear();
  tmp = this.deps;
  this.deps = this.newDeps;
  this.newDeps = tmp;
  this.newDeps.length = 0;
};

4.1 Vue.set 为什么不允许设置根 data ?

例如 Vue.set(app,'msg','value') , 这样app.msg是拿不到app.$data.msg的, 缺少了一层代理(在defineReactive函数中没有为它增加到$data的代理). 而如果是Vue.set(app.msg,'msg2','value'), 则是可以通过代理拿到app.$data.msg.msg2

简单来说, Vue.set(app,'msg','value')实际上就是执行defineReactive(app,'msg','value'), 而这个函数内部是没有写proxy

如果重新调整代码结构, 把 proxy 放入 defineReactive 函数中中执行, 那就 ok . 不过干脆期待 vue3.0的 es6 proxy 代理比较好, 比 Object.defineProperty 的功能更强大.

4.2 用户手动添加 watcher 导致 update loop 时的循环流程

和 watcher 相关的全局变量有has = { } , waiting = false, flushing = false, index //queue
watcher的 queue 加入顺序, 实例流程解析如下

var app = new Vue({
  data : { msg : 1, count: 0 },
  watch : { msg(){ this.count++<100 && this.msg = Math.random() }
})
1. 首次渲染后, 第一次改变 msg 的数值时, watcher 的队列内容如下
queue = [ { id:1, expression:"msg"}, { id:2, expression:" ... vm._update(vm._render())" } ]
has = { 1: true , 2: true }
2. 接着, 若此时 waiting 为 false, 则置 waiting 为 true, 置 flushing 为 true,注册nextTick(flushSchedulerQueue)
3. 执行flushSchedulerQueue时, 首先取出 queue[0], 设置 has[1] = null , 执行该 watcher 的 .run 函数时, 
发现处理好新旧 value 后, 在调用 callback(cb)的过程中, 碰到了用户代码的 this.msg = Math.random() 语句,
则再次触发一轮新的 代理 setter 过程
4. 在新的一轮代理 setter 过程中, 订阅者仍然是["msg", "... vm._update(vm._render())"]两人, 此时
has = [ 1: null, 2: true ], 所以前者可以以插队形式(正好插在{id:2}的前面)加入 queue, 后者的插队被拒绝(因为 has[2] 为 true).

循环若干次后, queue 的状态会变为:
queue = [
 { id:1, expression : "msg" },
 { id:1, expression : "msg" },
 { id:1, expression : "msg" },
...
 { id:2, expression : "... vm._update(vm._render())" }, 
]

5.最终, 达到100次后(若超过100次则会抛出"infinite update loop"异常), 不再向 queue 中添加新的内容, 
index 终于可以如愿执行至{ id:2 }的 watcher,  DOM 被更新

总结: 可以看到, 得益于nextTick 的异步机制, msg 这个数据执行了100次(数量取决于用户代码)setter后才刷新一次 DOM, 性能表现很好.

4.3 this.$nextTick( ) 异步更新

Vue 内部检测到数据变化后会将 watcher 添加入 queue, 而 DOM 刷新是放在了nextTick(flushSchedulerQueue)中, 也就是说 DOM 的更新是个异步过程, 同时用户自定义 watch 函数也是异步的, 作为验证, 可以测试如下代码, watch 内的函数只执行了一次

var app = new Vue({
  // ...
  data : { msg : 1 },
  methods : { 
    change(){ 
        [2,3,4,5].forEach(x => this.msg = x) 
    }
  },
  watch: { 
    msg() { 
      console.log(arguments)  //该函数只会触发一次
    } 
  }
}

如果要获取修改后的 DOM, 可以调用 this.$nextTickVue.nextTick, 二者完全一致

  • this.$nextTick(fn) 会将 fn 加入 callbacks 队列
  • this.$nextTick()会生成一个状态为resolvedPromise 对象, 并将该存储于函数闭包中的_resolve加入 callbacks 队列
  • 若闭包中的变量pending为 false, nextTick 函数会执行macroTimerFunc()microTimerFunc()来异步执行 flushCallbacks.
    • macroTimerFunc实质上等于messageChannel触发onmessage事件,该事件的回调是flushCallbacks
    • microTimerFunc实质上等于()=>Promise.resolve().then(flushCallbacks)
var app = new Vue({
  // ...
  data : { msg : 1 },
  methods : { 
    async change(){ 
        this.msg = 2
        console.log('sync:', this.$refs.msg.innerText) // sync: 1
        /* 下面三种写法效果一致, 都会输出 2 */ 
        this.$nextTick(()=>{
              console.log('nextTick:', this.$refs.msg.innerText)
        })
        this.$nextTick().then(()=>{
              console.log('nextTick with promise:', this.$refs.msg.innerText)
        })
        await this.$nextTick()
        console.log('sync:', this.$refs.msg.innerText)
    }
  },

有所区别的是, 下面的代码会输出1

  async change(){ 
        this.msg = 2
        Promise.resolve().then(()=>console.log('promise:', this.$refs.msg.innerText)) //1
    }

因为该函数里的 Promise 是在这一轮 event-loop 的末尾执行的, 而 nextTick 的回调是在下一轮event-loop 的开头执行的

关于这点, 单步调试可发现 nextTick 中在macromicro二选一时选择了macro

更新: vue-2.6中作者权衡利弊后又把 nextTick 全部改为了 microTask, 参见2.6 Internal Change: Reverting nextTick to Always Use Microtask

4.4 Vue 如何实现 computed 计算属性 ?

示例代码:

var app= new Vue({
 //...
 computed:{
            name(){
                return this.useless > 0? this.firstName+ ', ' +this.lastName : 'please click change'
            }
  },
})

4.4.1 计算属性注册:

  1. initState函数中发现$options中有 computed 属性, 则调用 initComputed 函数
  2. 在 vm 上挂载vm._computedWatchers属性,初始化为{ }, 该属性是为计算属性专属, 如果用户 options 中没有计算属性, 则它不会出现
  3. 遍历computed , 例如本例中只会遍历一次name
  • 获取computed[name]对应的 getter:
    var getter = typeof userDef === 'function' ? userDef : userDef.get;
  • 新建一个 watcher
    watchers[name] = new Watcher(vm, getter, noop, { lazy :true });
    注意该 watcher 的lazy属性和dirty属性都为 true, 这里做了一个缓存机制
  • Object.defineProperty(vm, key /* name */, sharedPropertyDefinition);

其中sharedPropertyDefinition的 getter 是由 createComputedGetter 函数来生成的, 放到下面取值的过程讲. setter 我们暂时忽略

4.4.2 计算属性的 getter

function createComputedGetter (key) {
  return function computedGetter () {
    var watcher = this._computedWatchers && this._computedWatchers[key];
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate();
      }
      if (Dep.target) {
        watcher.depend();
      }
      return watcher.value
    }
  }
}

当渲染 DOM 需要用到name时, 检测dirty标志, 判断该计算属性是否被修改过,

  • 若无, 则返回之前的缓存值watcher.value
  • 若有, 则置dirty为 false , 执行watcher.evaluate()获取新的值
    • 在该函数中, 会调用this.get(), 而此处会判断数值是否变动来决定下一步操作, 例如'firstName' + 'lastName' === 'firstNam'+'elastName', 此时两个响应式属性的变动会将 name 的 watcher 添加到队列中并执行, 但 name的 watcher 执行了自己的this.get()后发现自己没变化, 就不需要把渲染 watcher 再添加到队列了.
  • 在渲染 watcher 的上下文环境中要做依赖收集//若是控制台无聊输出 vm.name 则不需要
    接下来看看this.firstName的变动是如何让namedirty变化的fase => true

4.4.3 data 变动引起 computed 值变动的过程

  1. 首先, this.useless会变为true, 会 dep.notify( ) namewatcher 和DOMwatcher
  • update namewatcher 的过程就是设置它的this.dirty为 true
  • 将 DOM 渲染 watcher 放入队列
    提问: 为什么 useless 会同时拥有两个 watcher?为什么不是 useless 通知 name 更新, name 再通知 dom 更新?
    答: 因为代码中有这样一部分, 计算属性取值时watcher.evaluate()后, 又执行了watcher.depend(), 该方法中会执行this.deps[i].depend(), 于是就把 dom 渲染 watcher 也给 useless 和 firstName 等每人依赖了一份. 相对的, 在数据变动而 notify 它的 watcher 更新时, 不会把这两个 watcher 都放入队列, 而是只把计算书行的 dirty 设置为 true, 把 dom渲染 watcher 放入队列.
  1. nextTick 清空 callBacks 队列 => 清空 flushSchedulerQueue 队列
    在这个过程中, dom 渲染 watcher.run()时, 会重新收集依赖.

4.4.4 如果 data 变了但是 computed 不变会怎么办?

关于这件事的详细讨论可以参考

  1. github 博客 深入浅出 - vue变化侦测原理
  2. vue-2.5.17-beta0中引入的PR 尤大写的 PR 事实上该 beta 版本未被合入2.5.17正式版中

考虑如下的代码

示例代码1

computed:{
  name() { return this.a + this.b }
},
methods:{
  change() { this.a++; this.b-- }
}

示例代码2

computed:{
  name() { return this.a > 0 ? this.b : 0 }
},
methods:{
  change() { this.a++ }
}

2.5.17~2.6.10的版本中

会在同步代码执行完毕后, 判断新旧 vnode 相同的部分来达到不重绘dom 的目的, 但是生成新的 vnode 时还是用了不少时间.

2.5.17-beta.0的版本中

作者尝试使用watcher.getAndInvoke函数来实现计算属性不变则不重绘的目的, 结果:

  • 示例代码2表现效果非常好
  • 但对示例代码1会发现事与愿违, 由于event-loop的关系, 上述代码反而会让name()发现自己被改变了2次, 进而触发两次创建新 vnode, 进而触发2次重绘 dom.

那么理想的解决办法是什么呢?

理想的执行顺序为:
change()改变 data(同步) => name()求值(异步) => 根据需要重绘 dom

目前在2.5.17版本中, 重绘 dom的异步是在 macrotask(messageChannel)中实现的
而在^2.6.10版本中, 重绘 dom的异步全部使用 microTask(Promise)

那么, 如果想要让 computed 的求值异步任务放在重绘 DOM 之前, 就要构造一个优先级比 Promise 更高的 microtask. 我很期待 Vue 3.0 给我们带来的改变!

5. 编译

关于简单的 HTMLParser 请移步我的另一篇文章 HTMLParser 的实现和使用, 下面主要纪录 Vue 的start end等钩子函数中做了什么.

5.1 AST Node 的分类

  • type:1 普通 tag , 例如{ type:1, tag:'div ,attrs}
  • type:2 模板语法字符串, 例如 { type:2, text:'{{msg}}', expression:'_s(msg)', tokens }
  • type:3 纯文本字符串, 例如{ type:3, text:'hello world' }
  • type:3 注释字符串, { type:3, text, isComment:true }

5.2 钩子函数

5.2.1 comment

function comment (text: string) {
      currentParent.children.push({
        type: 3,
        text,
        isComment: true
      })//纯文本注释
    }

5.2.2 chars

function chars (text: string) {
      const children = currentParent.children
      //对于一般</ul>闭合前的若干空格, text.trim()会变成长度为0的"",此时一般保留1个空格
      //我也不知道为什么,明明下面 end()中又把这个空格 pop 掉了
      text = text.trim() ? isTextTag(currentParent) ? text : decodeHTMLCached(text)
                         // only preserve whitespace if its not right after a starting tag
                         : preserveWhitespace && children.length ? ' ' : ''
      if (text) {
        let res
        if (text !== ' ' && (res = parseText(text, delimiters))) {
          children.push({
            type: 2,
            expression: res.expression,
            tokens: res.tokens,
            text
          })//模板字符串,由 "{{msg}}" 转为 "_s(msg)"
        } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
          children.push({
            type: 3,
            text
          })//纯文本节点
        }
      }
    }

5.2.3 start

function start(tag, attrs, unary) {
    /* @type{ type:1, tag, parent, children, attrsList, attrsMap } */
    let element  = createASTElement(tag, attrs, currentParent)

    // apply pre-transforms 如果 tag 为 input 的话处理 v-model 相关
    for (let i = 0; i < preTransforms.length; i++) {
        element = preTransforms[i](element, options) || element
    }
    if (!element.processed) {
        processFor(element)     /* 处理 v-for, 例如对 v-for="(item,index) in data"处理为
                                   Object.assign(element,{ alias:"item", for:"data", iterator1:"index" }) */
        processIf(element)      /* 处理 v-if, 例如对 v-if="isShow" 处理为 element.if="isShow",
                                   同时设置 elment.ifConditions = [{ exp:"isShow", block:element }] */
                                /* 另外,遇到attrs 含有 v-else 节点时,标记 { else:true }, 然后在下面 processIfConditions 处理*/

        processOnce(element)    //处理 v-once
        processElement(element, options)/* 处理 ref slot component, 
                                         * transform[0] : 处理 staticClass, classBinding
                                         * transform[1] : 处理 staticStyle, styleBinding 
                                         */
    }

    // tree management
    if (!root) {
        root = element
    } 
    if (currentParent) {
        if (element.elseif || element.else) {
            processIfConditions(element, currentParent) /* 对 v-else 节点vel,在当前父亲下寻找前面的 v-if 节点vif,并设置
                                                           vif.ifConditions.push({ 
                                                               exp:vel.elseif, 
                                                               block:vel 
                                                           }) */
        } else if (element.slotScope) { // scoped slot
            currentParent.plain = false
            const name = element.slotTarget || '"default"'
                ; (currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element
        } else {
            currentParent.children.push(element)
            element.parent = currentParent
        }
    }
    if (!unary) {
        currentParent = element
        stack.push(element)
    } else {
        closeElement(element)
    }
}

5.2.4 end

function end() {
    //去除尾部空白字符,例如
    /* <ul>
          <li></li>
       </ul> //此时 ul 会有2个 child,一个是 li,一个是 li 后面的空格,所以要去除空格
    */
    var element = stack[stack.length - 1];
    var lastNode = element.children[element.children.length - 1];
    if (lastNode && lastNode.type === 3 && lastNode.text === ' ' && !inPre) {
        element.children.pop();
    }
    // pop stack
    stack.length -= 1;
    currentParent = stack[stack.length - 1];
}

5.3 optimize 优化

  • optimize 的目标是通过标记静态根的方式, 优化重新渲染过程中对静态节点的处理逻辑
  • optimize 的过程就是深度遍历这个 AST 树,先标记静态节点, 在标记静态根
  • 静态节点: 例如 <p>123</p>, 即子节点都要是静态节点, 且自己能通过isStatic
  • 静态根: node.type必须为1 且node.static==1node.children必须有>=1个非纯文本孩子, 称之为静态根
    • <div><p>111</p></div>是静态根
    • <ul><li>1</li><li>2</li><li>3</li>是静态根
    • <li>1</li>不是静态根, 虽然它是静态节点

单个静态节点的判定:

function isStatic (node: ASTNode): boolean {
  if (node.type === 2) { // expression
    return false
  }
  if (node.type === 3) { // text
    return true
  }
  return !!(node.pre || (
    !node.hasBindings && // no dynamic bindings
    !node.if && !node.for && // not v-if or v-for or v-else
    !isBuiltInTag(node.tag) && // not a built-in
    isPlatformReservedTag(node.tag) && // not a component
    !isDirectChildOfTemplateFor(node) &&
    Object.keys(node).every(isStaticKey)
  ))
}

5.4 codegen 代码生成

5.4.1 codegen 的输入和输出

下面的示例代码

<ul :class="bindCls" class="list" v-if="isShow">
    <li v-for="(item,index) in data" @click="clickItem(index)">{{item}}:{{index}}</li>
</ul>

会被编译成如下渲染函数

function anonymous(
) {
  with(this){
  return (isShow) ?
    _c('ul', {
        staticClass: "list",
        class: bindCls
      },
      _l((data), function(item, index) {
        return _c('li', {
          on: {
            "click": function($event) {
              clickItem(index)
            }
          }
        },
        [_v(_s(item) + ":" + _s(index))])
      })
    ) : _e()
 }
}

可以在渲染函数-模板编译测试一下

其中用到的_c这些下划线函数可以在src/core/instance/render-helpers/index.js中找到

export function installRenderHelpers (target: any) {
  target._o = markOnce
  target._n = toNumber
  target._s = toString
  target._l = renderList
  target._t = renderSlot
  target._q = looseEqual /* Check if two values are loosely equal - that is,
                         if they are plain objects, do they have the same shape? */
  target._i = looseIndexOf // 判断是否相等时使用上面的函数的数组 indexOf 
  target._m = renderStatic
  target._f = resolveFilter
  target._k = checkKeyCodes
  target._b = bindObjectProps
  target._v = createTextVNode
  target._e = createEmptyVNode
  target._u = resolveScopedSlots
  target._g = bindObjectListeners
}

5.4.2 我自己尝试编写的简单的 codegen 逻辑

其中 js 字符串排版使用了beautify.js
其中输入的 ast 是optimize 优化过的 ast 结构


function generate(node) {
    var res = "function anonymous(){with(this){return "
    res += gen(node);
    return res + "}}"

    function gen(el) {
        if (el.type == 1) {
            debugger
            if (el.for && !el.forProcessed) {
                el.forProcessed = true
                return genFor(el)

            } else if (el.if && !el.ifProcessed) {
                el.ifProcessed = true
                return genIf(el)
            } else {
                var data = el.plain ? undefined : JSON.stringify(el.attrsMap)
                var children = ""
                if (el.children.length === 1 && el.children[0].for) {
                    children = gen(el.children[0])
                }
                else {
                    children = '[' + el.children.map(x => gen(x)).join(',') + ']'
                }
                code = `_c('${el.tag}'${data ? ("," + data) : ''}${children ? ("," + children) : ''})`;
                return code
            }
        } else if (el.type == 2) {
            return `_v(${el.expression})`
        } else if (el.type == 3 && el.text.trim()) {
            return `_v(${el.text})`
        } else {
            return ''
        }
        function genIf(el) {
            return (function genIfConditions(conditions) {
                var leftCondition = conditions.shift()
                if (leftCondition && leftCondition.exp) {
                    return '(' + leftCondition.exp + ')?' + gen(leftCondition.block) + ':' + genIfConditions(conditions)
                }
                else {
                    return "_e()"
                }
            })(el.ifConditions.slice())
        }
        function genFor(el) {
            return `_l((${el.for}),function(${el.alias},${el.iterator1}){ return ${gen(el, false)}})`
        }
    }
}

整体思路比较简单, 对于文本节点使用_v,对于iffor做了特殊的处理,下面看一下对同一段 DOM 的测试结果:

//测试
js_beautify(generate(ast),{ indent_size: 2, space_in_empty_paren: true })
//测试结果
function anonymous() {
  with(this) {
    return (isShow) ? _c('ul', {
      ":class": "bindCls",
      "class": "list",
      "v-if": "isShow"
    }, _l((data), function(item, index) {
      return _c('li', {
        "v-for": "(item,index) in data",
        "@click": "clickItem(index)"
      }, [_v(_s(item) + ":" + _s(index))])
    })) : _e()
  }
}

可以看到, 我写的简单 generate 和 vue 的, 对同一段简单 DOM 生成的 render 函数基本一致, 所以原理基本搞清楚了, 但有些细微差别:

  1. 我没处理{ on: { "click": function($event) { clickItem(index) } } 这种结构

  2. 我没处理node.staticRoot这些静态根节点

  3. 我没处理组件

  4. 对于v-forv-if 我的处理和 vue 一致, 其中包括:

    • v-for且 children 数组长度为1时, 不生成[ gen(el) ] 而是直接生成 gen(el), 即将此种孩子提升了一级. 实际上_c是能接收_l产生的结果的
    • v-if中良好的处理了v-if v-elseif v-elseif v-else这种多级结构

5.4.3 Vue对 staticRoot 节点的处理

Vue 会将这类 node 渲染为一个新的function anonymous(){ with(this) //... }, 并 push 入staticRenderFns中, 然后它的 codegen 就是返回该函数的序号, 例如_m(0).
此外, Vue 还以简单的 cached[template] 的形式对模板生成的 render 函数和staticRenderFns 进行了缓存

6 扩展

6.1 Vue 和 React 事件绑定的this 对比

="handler" ="handler()" ="()=>handler()"
Vue 编译为with(this){ .... handler() }
成功绑定回调和this
编译为with(this){ .... function($event){ handler() }
成功绑定回调和 this
with(this){ .... 'click':()=>handler() } 在Render时绑定this到箭头函数
React 能触发事件,但是直接执行handler()未绑定 this 在 Render 时会触发一次 handler(), 然后将 handler()的返回值传入addEventListener, 可能为 undefined 而导致事件没有回调 成功绑定回调, 在 Render 时绑定 this 到箭头函数

6.2 为什么@click="alert(1)" 或者 @click="console.log(1)"会报错

这个问题出现在 vue2.5.17-2.6.10 的非 production 版本上, 如果使用 production 版本则问题消失. 检查源码可以发现在开发版的 vue 生命周期中有一个initProxy函数, 为 vm 挂载了vm._renderProxy属性, 此时, 在执行 render 函数访问其中属性时, 会优先访问代理属性. 即, 访问 console.log(1), 会优先访问 this.console

vue 的 render 函数大概长这样:

function anonymous() {
    with (this) {
        return _c('div', [_c('p', {
            on: {
                "click": function($event) {
                    return console.log(1)
                }
            }
        })])
    }
}

本来, 在 with(this) 的函数中, 访问 this.console 如果是 undefined, 会再次访问上级作用域来寻找 console 值. 但是 开发版的 vue 做了代理,

  const hasHandler = {
    has (target, key) {
      const has = key in target
      const isAllowed = allowedGlobals(key) ||
        (typeof key === 'string' && key.charAt(0) === '_' && !(key in target.$data))
      if (!has && !isAllowed) {
        if (key in target.$data) warnReservedPrefix(target, key)
        else warnNonPresent(target, key)
      }
      return has || !isAllowed
    }
  }

这个代理导致系统在判断 vm.console 是否存在时, has值为false, isAllowedfalse , 因此系统觉得 vm.console 存在, 就不去访问 window 了, 于是出错

总之, 还是不推荐在@click=""里直接写alert console window这些全局作用域里有的东西, 只写 vm 的作用域里有的东西比较好. 或者把alert console window转移至methods中去

6.3 语法糖 v-model

对普通元素的 v-model 有下面的等价关系

<input v-model="message">

<input v-bind:value="message"
       v-on:input="if($event.target.composing) return; message=$event.target.value">

对组件的 v-model, 语法糖等价关系变为了

//子组件 
let Child = {
  template: '<div>'
  + '<input :value="value" @input="updateValue" placeholder="edit me">' +
  '</div>',
  props: ['value'],
  methods: {
    updateValue(e) {
      this.$emit('input', e.target.value)
    }
  }
}
// 父组件 v-model 写法 
let vm = new Vue({
  el: '#app',
  template: '<div>' +
  '<child v-model="message"></child>' +
  '<p>Message is: {{ message }}</p>' +
  '</div>',
  data() {
    return {
      message: ''
    }
  },
  components: {
    Child
  }
})
//父组件语法糖写法
let vm = new Vue({
  el: '#app',
  template: '<div>' +
  '<child :value="message" @input="message=arguments[0]"></child>' +
  '<p>Message is: {{ message }}</p>' +
  '</div>',
  data() {
    return {
      message: ''
    }
  },
  components: {
    Child
  }
})
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,902评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,037评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,978评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,867评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,763评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,104评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,565评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,236评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,379评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,313评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,363评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,034评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,637评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,719评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,952评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,371评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,948评论 2 341

推荐阅读更多精彩内容

  • #1 Commit ID: 871ed9126639c9128c18bb2f19e6afd42c0c5ad9 La...
    DogRod阅读 210评论 0 0
  • mean to add the formatted="false" attribute?.[ 46% 47325/...
    ProZoom阅读 2,689评论 0 3
  • # 传智播客vue 学习## 1. 什么是 Vue.js* Vue 开发手机 APP 需要借助于 Weex* Vu...
    再见天才阅读 3,519评论 0 6
  • 气候对中原王朝与游牧民族关系的影响——以汉朝为中心。 气候与国家有着密不可分的关系,从一副气象图上可以得知:气候较...
    荆瑶阅读 298评论 0 1
  • 今天是很有意义的一天,下午到客户家收住院资料理赔,晚上区上给到的个人荣誉晚宴,邀约了部分家人来见证和捧场,很幸福,...
    卓彤的美好时光阅读 55评论 0 0