V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
ouyangcoder
V2EX  ›  推广

[vue3 编译原理揭秘] Vue 3 的 setup 语法糖到底是什么东西?

  •  
  •   ouyangcoder · 12 天前 · 443 次点击

    前言

    我们每天写vue3项目的时候都会使用setup语法糖,但是你有没有思考过下面几个问题。setup语法糖经过编译后是什么样子的?为什么在setup顶层定义的变量可以在template中可以直接使用?为什么import一个组件后就可以直接使用,无需使用components 选项来显式注册组件?

    vue 文件如何渲染到浏览器上

    要回答上面的问题,我们先来了解一下从一个vue文件到渲染到浏览器这一过程经历了什么?

    我们的vue代码一般都是写在后缀名为 vue 的文件上,显然浏览器是不认识 vue 文件的,浏览器只认识 html 、css 、jss 等文件。所以第一步就是通过webpack或者vite将一个 vue 文件编译为一个包含render函数的js文件。然后执行render函数生成虚拟 DOM ,再调用浏览器的DOM API根据虚拟 DOM 生成真实 DOM 挂载到浏览器上。

    setup编译后的样子

    javascript标准中script标签是不支持setup属性的,浏览器根本就不认识setup属性。所以很明显setup是作用于编译时阶段,也就是从 vue 文件编译为 js 文件这一过程。

    我们来看一个简单的 demo ,这个是index.vue源代码:

    <template>
      <h1>{{ title }}</h1>
      <h1>{{ msg }}</h1>
      <Child />
    </template>
    
    <script lang="ts" setup>
    import { ref } from "vue";
    import Child from "./child.vue";
    
    const msg = ref("Hello World!");
    const title = "title";
    if (msg.value) {
      const content = "content";
      console.log(content);
    }
    </script>
    

    这里我们定义了一个名为msgref响应式变量和非响应式的title变量,还有importchild.vue组件。

    这个是child.vue的源代码

    <template>
      <div>i am child</div>
    </template>
    

    我们接下来看index.vue编译后的样子,代码我已经做过了简化:

    import { ref } from "vue";
    import Child from "./Child.vue";
    
    const title = "title";
    
    const __sfc__ = {
      __name: "index",
      setup() {
        const msg = ref("Hello World!");
        if (msg.value) {
          const content = "content";
          console.log(content);
        }
        const __returned__ = { title, msg, Child };
        return __returned__;
      },
    };
    
    import {
      toDisplayString as _toDisplayString,
      createElementVNode as _createElementVNode,
      createVNode as _createVNode,
      Fragment as _Fragment,
      openBlock as _openBlock,
      createElementBlock as _createElementBlock,
    } from "vue";
    function render(_ctx, _cache, $props, $setup, $data, $options) {
      return (
        _openBlock(),
        _createElementBlock(
          _Fragment,
          null,
          [
            _createElementVNode("h1", null, _toDisplayString($setup.title)),
            _createElementVNode(
              "h1",
              null,
              _toDisplayString($setup.msg),
              1 /* TEXT */
            ),
            _createVNode($setup["Child"]),
          ],
          64 /* STABLE_FRAGMENT */
        )
      );
    }
    __sfc__.render = render;
    export default __sfc__;
    

    我们可以看到index.vue编译后的代码中已经没有了template标签和script标签,取而代之是render函数和__sfc__对象。并且使用__sfc__.render = renderrender函数挂到__sfc__对象上,然后将__sfc__对象export default出去。

    看到这里你应该知道了其实一个vue组件就是一个普通的 js 对象,import一个vue组件,实际就是import这个js对象。这个 js 对象中包含render方法和setup方法。

    编译后的setup方法

    我们先来看看这个setup方法,是不是觉得和我们源代码中的setup语法糖中的代码很相似?没错,这个setup方法内的代码就是由setup语法糖中的代码编译后来的。

    setup语法糖原始代码

    <script lang="ts" setup>
    import { ref } from "vue";
    import Child from "./child.vue";
    
    const msg = ref("Hello World!");
    const title = "title";
    if (msg.value) {
      const content = "content";
      console.log(content);
    }
    </script>
    

    setup编译后的代码

    import { ref } from "vue";
    import Child from "./Child.vue";
    
    const title = "title";
    
    const __sfc__ = {
      __name: "index",
      setup() {
        const msg = ref("Hello World!");
        if (msg.value) {
          const content = "content";
          console.log(content);
        }
        const __returned__ = { title, msg, Child };
        return __returned__;
      },
    };
    

    经过分析我们发现title变量由于不是响应式变量,所以编译后title变量被提到了js文件的全局变量上面去了。而msg变量是响应式变量,所以依然还是在setup方法中。我们再来看看setup的返回值,返回值是一个对象,对象中包含titlemsgChild属性,非setup顶层中定义的content变量就不在返回值对象中。

    看到这里,可以回答我们前面提的第一个问题。

    setup语法糖经过编译后是什么样子的?

    setup语法糖编译后会变成一个setup方法,编译后setup方法中的代码和script标签中的源代码很相似。方法会返回一个对象,对象由setup中定义的顶层变量和import导入的内容组成。

    template编译后的render函数

    我们先来看看原本template中的代码:

    <template>
      <h1>{{ title }}</h1>
      <h1>{{ msg }}</h1>
      <Child />
    </template>
    

    我们再来看看由template编译成的render函数:

    import {
      toDisplayString as _toDisplayString,
      createElementVNode as _createElementVNode,
      createVNode as _createVNode,
      Fragment as _Fragment,
      openBlock as _openBlock,
      createElementBlock as _createElementBlock,
    } from "vue";
    function render(_ctx, _cache, $props, $setup, $data, $options) {
      return (
        _openBlock(),
        _createElementBlock(
          _Fragment,
          null,
          [
            _createElementVNode("h1", null, _toDisplayString($setup.title)),
            _createElementVNode(
              "h1",
              null,
              _toDisplayString($setup.msg),
              1 /* TEXT */
            ),
            _createVNode($setup["Child"]),
          ],
          64 /* STABLE_FRAGMENT */
        )
      );
    }
    

    我们这次主要看在render函数中如何访问setup中定义的顶层变量titlemsgcreateElementBlockcreateElementVNode等创建虚拟 DOM 的函数不在这篇文章的讨论范围内。你只需要知道createElementVNode("h1", null, _toDisplayString($setup.title))为创建一个h1标签的虚拟 DOM 就行了。

    render函数中我们发现读取title变量的值是通过$setup.title读取到的,读取msg变量的值是通过$setup.msg读取到的。这个$setup对象就是调用render函数时传入的第四个变量,我想你应该猜出来了,这个$setup对象就是我们前面的setup方法返回的对象。

    那么问题来了,在执行render函数的时候是如何将setup方法的返回值作为第四个变量传递给render函数的呢?我在下一节会一步一步的带你通过debug源码的方式去搞清楚这个问题,我们带着问题去debug源码其实非常简单。

    debug源码搞清楚是如何调用render函数

    有的小伙伴看到这里需要看源码就觉得头大了,别着急,其实很简单,我会一步一步的带着你去 debug 源码。

    首先我们将Enable JavaScript source maps给取消勾选了,不然在 debug 源码的时候断点就会走到vue文件中,而不是走到编译会的 js 文件中。

    然后我们需要在设置里面的 Ignore List 看看node_modules文件夹是否被忽略。新版谷歌浏览器中会默认排除掉node_modules文件夹,所以我们需要将这个取消勾选。如果忽略了node_modules文件夹,那么debug的时候断点就不会走到node_modulesvue的源码中去了。

    接下来我们需要在浏览器中找到 vue 文件编译后的 js 代码,我们只需要在network面板中找到这个vue文件的http请求,然后在Response下右键选择Open in Sources panel,就会自动在sources面板自动打开对应编译后的 js 文件代码。

    找到编译后的 js 文件,我们想debug看看是如何调用render函数的,所以我们给 render 函数加一个断点。然后刷新页面,发现代码已经走到了断点的地方。我们再来看看右边的 Call Stack 调用栈,发现render函数是由一个vue源码中的renderComponentRoot函数调用的。

    点击 Call Stack 中的renderComponentRoot函数就可以跳转到renderComponentRoot函数的源码,我们发现renderComponentRoot函数中调用render函数的代码主要是下面这样的:

    function renderComponentRoot(instance) {
      const {
        props,
        data,
        setupState,
        // 省略...
      } = instance;
    
      render2.call(
        thisProxy,
        proxyToUse,
        renderCache,
        props,
        setupState,
        data,
        ctx
      )
    }
    

    这里我们可以看到前面的$setup实际就是由setupState赋值的,而setupState是当前 vue 实例上面的一个属性。那么setupState属性是如何被赋值到vue实例上面的呢?

    我们需要给setup函数加一个断点,然后刷新页面进入断点。通过分析 Call Stack 调用栈,我们发现setup函数是由vue中的一个setupStatefulComponent函数调用执行的。

    点击 Call Stack 调用栈中的setupStatefulComponent,进入到setupStatefulComponent的源码。我们看到setupStatefulComponent中的代码主要是这样的:

    function setupStatefulComponent(instance) {
      const { setup } = Component;
      // 省略
      const setupResult = callWithErrorHandling(
        setup,
        instance
      );
      handleSetupResult(instance, setupResult);
    }
    

    setup函数是Component上面的一个属性,我们将鼠标放到Component上面,看看这个Component是什么东西?

    看到这个Component对象中既有render方法也有setup方法是不是感觉很熟悉,没错这个Component对象实际就是我们的vue文件编译后的 js 对象。

    const __sfc__ = {
      __name: "index",
      setup() {
        const msg = ref("Hello World!");
        if (msg.value) {
          const content = "content";
          console.log(content);
        }
        const __returned__ = { title, msg, Child };
        return __returned__;
      },
    };
    
    __sfc__.render = render;
    

    从 Component 对象中拿到setup函数,然后执行setup函数得到setupResult对象。然后再调用handleSetupResult(instance, setupResult);

    我们再来看看handleSetupResult函数是什么样的,下面是我简化后的代码:

    function handleSetupResult(instance, setupResult) {
      if (isFunction(setupResult)) {
        // 省略
      } else if (isObject(setupResult)) {
        instance.setupState = proxyRefs(setupResult);
      }
    }
    

    我们的setup的返回值是一个对象,所以这里会执行instance.setupState = proxyRefs(setupResult),将 setup 执行会的返回值赋值到 vue 实例的 setupState 属性上。

    看到这里我们整个流程已经可以串起来了,首先会执行由setup语法糖编译后的setup函数。然后将setup函数中由顶层变量和import导入组成的返回值对象赋值给vue实例的setupState属性,然后执行render函数的时候从vue实例中取出setupState属性也就是setup的返回值。这样在render函数也就是template模版就可以访问到setup中的顶层变量和import导入。

    现在我们可以回答前面提的另外两个问题了:

    为什么在setup顶层定义的变量可以在template中可以直接使用?

    因为在setup语法糖顶层定义的变量经过编译后会被加入到setup函数返回值对象__returned__中,而非setup顶层定义的变量不会加入到__returned__对象中。setup函数返回值会被塞到vue实例的setupState属性上,执行render函数的时候会将vue实例上的setupState属性传递给render函数,所以在render函数中就可以访问到setup顶层定义的变量和import导入。而render函数实际就是由template编译得来的,所以说在template中可以访问到setup顶层定义的变量和import导入。。

    为什么import一个组件后就可以直接使用,无需使用components 选项来显式注册组件?

    因为在setup语法糖中import导入的组件对象经过编译后同样也会被加入到setup函数返回值对象__returned__中,同理在template中也可以访问到setup的返回值对象,也就可以直接使用这个导入的组件了。

    总结

    setup语法糖经过编译后就变成了setup函数,而setup函数的返回值是一个对象,这个对象就是由在setup顶层定义的变量和import导入组成的。vue在初始化的时候会执行setup函数,然后将setup函数返回值塞到vue实例的setupState属性上。执行render函数的时候会将vue实例上的setupState属性(也就是setup函数的返回值)传递给render函数,所以在render函数中就可以访问到setup顶层定义的变量和import导入。而render函数实际就是由template编译得来的,所以说在template中就可以访问到setup顶层定义的变量和import导入。

    关注(图 1 )公众号: [前端欧阳] ,解锁我更多 vue 原理文章。

    加我(图 2 )微信回复「资料」,免费领取欧阳研究 vue 源码过程中收集的源码资料,欧阳写文章有时也会参考这些资料。同时让你的朋友圈多一位对 vue 有深入理解的人。

    公众号微信

    2 条回复
    llanero
        1
    llanero  
       12 天前   ❤️ 1
    Livid
        2
    Livid  
    MOD
       12 天前
    @llanero 谢谢,这个主题已经被移动到 /go/promotions

    @ouyangcoder 推广软文请发到 /go/promotions

    你的账号上目前已经有降权标记。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2180 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 42ms · UTC 06:08 · PVG 14:08 · LAX 23:08 · JFK 02:08
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.