vue3源码 ☞ ref/toRefs

2021-04-03 16:56

今天在项目里对vant的弹窗组件popup又包了一层作为子组件Child,以实现弹窗的定制化。 父组件Father传递数据到Child。

interface Props {
  showDialog: Boolean
}

props是一个proxy对象。子组件可以用computed计算属性对props.showDialog进行转换。

const visible = computed({
  get: ()=>props.showDialog,
  set: newVal=>{
    context.emit('toggleStatus', newVal)
  }
})

但今天主要是为了看看 ref 干了什么。接下来会分成两部分:

  1. 在setup这个钩子里面的ref
  2. DOM节点的ref属性

一、在setup这个钩子里面的ref

setup(props, context){
  const {showDialog} = toRefs(props)
  const visible = ref(showDialog)
  watch(()=>visible.value, newVal=>{
    context.emit('toggleStatus', newVal)
  })
  return {
    visible,
  }
}

对象结构会使之失去响应性,而toRefs包裹的对象可以避免这一点。 子组件创建visible变量,用来控制popup组件是否显示。 用ref接收showDailog属性,返回一个响应式且可变的ref对象。再通过监测visible.value值的变化,触发父组件showDialog更新值。嗯,看起来好像保持了对源头的响应式连接了!我的子组件肯定可以接收到Father的更新了!

但这样做有个问题。控制台很快报出⚠️

reactivity.esm-bundler.js:336 Set operation on key "showDialog" failed: target is readonly.

toRefs文档原文:

将响应式对象转换为普通对象,其中结果对象的每个 property 都是指向原始对象相应 property 的 ref

那么,showDailog是怎么变成ref对象的呢?

toRefs方法遍历其参数object,给object的每个值加上__v_isRef = true属性:

class ObjectRefImpl<T extends object, K extends keyof T> {
  public readonly __v_isRef = true

  constructor(private readonly _object: T, private readonly _key: K) {}

  get value() {
    return this._object[this._key]
  }

  set value(newVal) {
    this._object[this._key] = newVal
  }
}

__v_isRef标记该对象为ref对象。 接下来看看ref方法的功能。

export function ref(value?: unknown) {
  return createRef(value)
}

function createRef(rawValue: unknown, shallow = false) {
  if (isRef(rawValue)) {
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}

ref接收的showDailog,当判断是一个ref对象时,返回它自己。当弹窗组件被关闭,visible被直接赋值为false,实际上就是在挑战props的readonly属性。控制台警告,并且拒绝执行你的赋值操作。

既然如此,那就给visible初始化为false,再监测showDialog触发visible即可。

const visible = ref(false)
const toggle = (newVal: boolean)=>(visible.value=newVal)
watch(()=>showDialog.value, newVal=>{
  toggle(newVal)
})
watch(()=>visible.value, newVal=>{
  console.log('visible=------', visible)
  context.emit('toggleStatus', newVal)
})

问题还没结束。如果是ref函数接收的参数不是ref对象呢?换句话说,new RefImpl(rawValue, shallow)的作用是什么?

class RefImpl<T> {
  private _value: T

  public readonly __v_isRef = true

  constructor(private _rawValue: T, public readonly _shallow = false) {
    this._value = _shallow ? _rawValue : convert(_rawValue)
  }

  get value() {
    track(toRaw(this), TrackOpTypes.GET, 'value')
    return this._value
  }

  set value(newVal) {
    if (hasChanged(toRaw(newVal), this._rawValue)) {
      this._rawValue = newVal
      this._value = this._shallow ? newVal : convert(newVal)
      trigger(toRaw(this), TriggerOpTypes.SET, 'value', newVal)
    }
  }
}

主要看set value(newVal)这个方法。ref方法并没有传递shallow参数,convert方法将这个值转换为一个响应式的proxy对象。

const convert = <T extends unknown>(val: T): T =>
  isObject(val) ? reactive(val) : val

DOM节点的ref属性

考虑一个问题,VUE3的ref是怎么做到获取DOM元素的?

比如一个DOM节点,绑定了ref='schedule'

<div ref="schedule"></div>

接下来,在setup里面,我们要创建一个ref对象,别忘了把这个ref对象return出去。

setup(){
  const schedule = ref<HTMLDIVELEMENT | null>(null)
  return {
    schedule
  }
}

这样,我们就可以在onMounted生命周期获取到这个DOM节点。

onMounted(() => {
  console.log(schedule)
})

这一切不禁让习惯了this.$refs.schedule一把梭的笔者陷入了沉思。怎么做到的?为什么要这样做?是不是有点反直觉?接下来,让我们走进源码康一康。

第一步,先从项目入口main.ts开始找:

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')

我们要去找createApp

export const createApp = ((...args) => {
  const app = ensureRenderer().createApp(...args)
  //..以下省略
}

function ensureRenderer() {
  return renderer || (renderer = createRenderer<Node, Element>(rendererOptions))
}

export function createRenderer<
  HostNode = RendererNode,
  HostElement = RendererElement
>(options: RendererOptions<HostNode, HostElement>) {
  return baseCreateRenderer<HostNode, HostElement>(options)
}

baseCreateRenderer方法中,暴露了createApp方法:

//...略
return {
  render,
  hydrate,
  createApp: createAppAPI(render, hydrate)
}

createAppAPI暴露app, 拥有mount方法。在mount方法里执行render函数,render函数接收两个参数,第一个参数vnode如果不是null,再执行patch函数:

patch(container._vnode || null, vnode, container)

runtime-core>renderer.ts L452,找到了patch函数的定义。在这里,终于找到了目的地。n2 是patch 函数的第二个参数 vnode

const { type, ref, shapeFlag } = n2
//...省略上千行的switch
// set ref
if (ref != null && parentComponent) {
  setRef(ref, n1 && n1.ref, parentSuspense, n2)
}

接下来,就来看看setRef

if (isString(ref)) {
  const doSet = () => {
    refs[ref] = value
 if (hasOwn(setupState, ref)) {
      setupState[ref] = value
 }
}

如果ref是字符串,判断setup暴露的setupState里是否有这个ref对象,如果有,将value赋值给ref。而这value,就是vnode.el。

value = vnode.el

再来看看setupState

const { i: owner, r: ref } = rawRef
const setupState = owner.setupState

rawRef 是 setRef 方法的第一个参数:

export const setRef = (
  rawRef: VNodeNormalizedRef,
  oldRawRef: VNodeNormalizedRef | null,
  parentSuspense: SuspenseBoundary | null,
  vnode: VNode | null
)=>{
  //...
}

在vnode.ts L305,可以看到VNode的属性 ref为VNodeNormalizedRef类型,如果props存在,调用normalizeRef

const normalizeRef = ({ ref }: VNodeProps): VNodeNormalizedRefAtom | null => {
  return (ref != null
 ? isString(ref) || isRef(ref) || isFunction(ref)
      ? { i: currentRenderingInstance, r: ref }
      : ref
    : null) as any
}

VNodeProps是VNode节点的props对象数组,在编译DOM节点时创建。在compile-core>src>parse.ts文件,找到了这个方法。

export function baseParse(
  content: string,
  options: ParserOptions = {}
): RootNode {
  const context = createParserContext(content, options)
  const start = getCursor(context)
  return createRoot(
    parseChildren(context, TextModes.DATA, []),
    getSelection(context, start)
  )
}

当dom节点绑定了ref属性,在编译时,baseCompile方法里,传进了两个参数:templateoptions。判断template如果为字符串,则对模板进行词法分析,生成AST抽象语法树成。在这个过程baseParse>parseChildren>parseElement>parseTag>parseAttrs>parseAttr中,parseAttr生成一个包含name=ref的对象,parseAttrs方法将这个对象push到props数组里面。 接下来,对AST进行转换。再调用transform方法,第一个参数是ast,第二个参数扩展baseCompile的第二个参数options,包括各个节点属性类型对应的transform方法。其中有一个transformElement方法,这个方法将props转换成带有type、loc、properties等参数的对象,并给node添加了codegenNode属性。

node.codegenNode = createVNodeCall(
  context,
  vnodeTag,
  vnodeProps,
  vnodeChildren,
  vnodePatchFlag,
  vnodeDynamicProps,
  vnodeDirectives,
  !!shouldUseBlock,
  false /* disableTracking */,
  node.loc
)

这个属性对应的值,将在generate阶段帮助优化。generate方法将ast生成可执行的js代码。

最后,总结一下。ref涉及到了模板编译、运行时和响应式的部分源码。模板编译将ref推进VNode的props属性数组中。运行时找到setupState上对应的ref响应式对象,赋值为该DOM节点。