头图

前段时间把Vue3源码学习了一遍,从reactivity到complier,大致过了一遍,对Vue3这个库底层逻辑有了更清晰的了解。我打算写个系列的文章,来通俗易懂的阐述源码逻辑。我并不会直接一上来就贴一大段一大段的源码。我想从比较简单的渲染逻辑开始,这一块源码逻辑比较好理解,学起来也比较容易建立信心。

完整代码地址:Simple-Vue3

构建pnpm项目

新建一个项目simple-mini-vue3

mkdir simple-mini-vue3

这里推荐直接用pnpm

cd simple-mini-vue3

pnpm init

新建pnpm-workspace.yaml文件,编写配置。

packages:
  - "packages/*"

因为所有的源码都在packages文件夹中,你可以理解为一个工作空间,你想用项目中其他独立的包,就可以根据workspace协议来链接。比如在源码中就是这么用的:

  "dependencies": {
    "@vue/runtime-core": "workspace:^",
    "@vue/shared": "workspace:^1.0.0"
  }

新建.npmrc文件,编写配置:

shamefully-hoist = true

shamefully-hoist主要作用就是将依赖包提升到根node_modules 目录下,避免幽灵依赖。

关于上述配置,可以参考pnpm官方,有更加详细的讲解。这不是本文的重点。

在根目录下新建packages文件夹,新建runtime-core 文件夹。cdruntime-core 文件夹中,执行npm init生成package.json。

编写配置:

{
  "name": "@vue/runtime-core",
  "version": "1.0.0",
  "module": "dist/runtime-core.esm-bundler.js",
  "buildOptions": {
    "name": "VueRuntimeCore",
    "formats": [
      "esm-bundler"
    ]
  }
}

关于上述package.json文件的配置,待会会有挨个的解释,先按下不表。

至此,一个简单的pnpm项目搭建完成。我们的目的是让项目运行起来。最好是直接把编写的 js/ts 源码打包一份,在html中引入它们,能够直接在浏览器中运行,实时查看我们完成的效果。

我们引入esbuild来做开发打包:

pnpm i -w esbuild -D

在项目根目录下,新建scripts文件夹,这个不属于项目源码,隶属于开发/发布新版本的辅助脚本文件,因此不能放在packages文件夹中。 然后在其scripts文件夹中,新建dev.js,这里编写项目打包脚本。

回到根目录的package.json中,定义开发运行脚本:

  "scripts": {
    "dev:runtime-core": "node scripts/dev.js runtime-core -f esm-bundler"
  },

该命令就是执行 scripts/dev.js 脚本文件,后面在跟其他参数,多个参数空格分割。

编写dev.js 打包脚本:

我们打算直接用浏览器ESM原生支持的功能,也就是 <script type="module">。 所以在编写打包脚本时,是不能用cjs那一套。

import esbuild from 'esbuild';
import minimist from 'minimist';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const __dirname = dirname(fileURLToPath(import.meta.url));

const args = minimist(process.argv.slice(2));

const target = args._[0];
const format = args.f;
const pkg = require(`../packages/${target}/package.json`);

const outputFormat = 'esm';

// packages/runtime-core/dist/reactivity.esm-bundler.js
const outfile = resolve(__dirname, `../packages/${target}/dist/${target}.${format}.js`);

esbuild
    .context({
            // 打包入口
        entryPoints: [resolve(__dirname, `../packages/${target}/src/index.js`)],
                // 输出文件
        outfile,
        bundle: true,
        sourcemap: true,
        format: outputFormat,
        globalName: pkg.buildOptions.name,
                // 平台指定为 浏览器
        platform: 'browser',
        target: 'es2016'
    })
    .then(ctx => ctx.watch());

引入minimist,来做node脚本命令行参数解析。pnpm i -w minimist -Dtarget变量就是我们在执行脚本中定义的runtime-core包名称,format 指定了 'esm' 打包方式。
打包后的输出路径:packages/runtime-core/dist/runtime-core.esm-bundler.js

我们来试下效果:

新建src,新建index.js

image.png
编写以下代码:

export const render = (vnode, container) => {
    console.log(vnode, container);
};

cd到项目根目录,执行打包命令

pnpm run dev:runtime-core

回到packages/runtime-core文件夹中,已经生成了打包好的dist目录。

image.png

并且生成好了map文件。

接着,我们在dist目录下新建index.html文件,编写以下代码:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>
    <body>
        <div id="app"></div>

        <script type="module">
            import { render } from './runtime-core.esm-bundler.js';

            render(
                {
                    key: 1
                },
                app
            );
        </script>
    </body>
</html>

通过浏览器原生支持的ESM功能,导入打包后的文件,浏览器打开index.html文件运行测试下:

image.png

可以看到结果已经成功了。

还记得上面我们在runtime-core包中package.json文件配置的各项字段不。结合打包脚本,就明白了:

name表示包名称
module字段,是因为我们打算用原生ESM功能,所以配置的是打包后的文件路径
buildOptions表示的相关的打包配置,包括指定name、打包格式等等

到这里,整个项目大致的结构、打包、预览效果已经跑通。接下来就是真正的进入源码世界。

h函数


Vue中,虚拟节点(VNode)都是通过h函数生成的。VNode记录了相关节点元素的关键信息:

  • type: 元素类型,是原生元素,还是自定义组件
  • props:元素的属性
  • children:子组件
  • el:关联的真实元素节点
  • anchor:在文档中的位置信息
  • 等等其他属性

在实现h函数之前,我们先考虑下,页面渲染会有几种情况:

  1. 纯文本渲染(静态文本)
  2. 带有属性的元素(样式、 class、 自定义属性)
  3. 有自己的子组件元素
  4. 自定义组件渲染

对应的以上情况,规定h函数对应的写法如下:

1:纯文本渲染(静态文本)

h('div')
h('div', '显示的内容')
  1. 带有属性的元素(样式、 class、 自定义属性)
// 第二个参数一定是属性
h('div', {}, '显示的内容')
h('div', {}, h('span'))
h('div', {}, [''])  
h('div', {}, [h('span')])
  1. 有自己的子组件元素
h('div', [])
h('div', 'text')
h('div', h('span'))
h('div', {}, [''])        // 数组文本children
h('div', {}, [h('span')])  // 数组 VNode children
  1. 自定义组件渲染
h(Components, () => {})  // 默认插槽
h(Component, {}, () => {}) // 默认插槽
h(Component, {}, {}) // 具名插槽

总结下:

  • h函数至少得有一个参数得表明要渲染的节点,比如原生节点divspan这种,还是自定义的Components组件。
  • 当有2个参数时,第二个参数有可能是属性,也有可能是子节点,子节点有可能是纯文本,也有可能是VNode节点
  • 当有三个参数时,那就是最标准的入参,有要渲染的节点类型type,属性props和子节点
  • 当大于三个参数时,只会取最后两个参数当作子节点渲染

为了方便理解,我们就从最简单的开始:h('div')

runtime-core包中src文件下新建h.js文件

// h.js
export function h(type, propsOrChildren, children, ..._) {
   return createVNode(type, propsOrChildren, children);
}

不要忘记在index.js入口文件导出所有函数

// index.js
export * from './h';

接下来实现createVNode逻辑

createVNode函数

新建vnode.js:

// vnode.js
export function createVNode(type, props, children) {
    const vnode = {
        type,
        props,
        children,
        el: null // 真实节点 初始化为null
    };
    return vnode;
}

逻辑很简单,生成一个vnode对象,vnode保存了一些关键信息:type、props、children、el

现在VNode已经生成好了,Vue中渲染VNode节点信息,都是通过render函数来做的。

实现render函数:

render函数

新建renderer.js

// renderer.js
const patchElement = (n1, n2) => {
    // diff 流程
};

const mountElement = (vnode, container) => {
    const { type } = vnode;
    // 创建真实节点
    const el = (vnode.el = document.createElement(type));

    container.insertBefore(el, null);
};

const processElement = (n1, n2, container) => {
    if (n1 === null) {
        // 第一次挂载、创建
        mountElement(n2, container);
    } else {
        // diff  更新
        patchElement(n1, n2);
    }
};

const patch = (n1, n2, container) => {
    processElement(n1, n2, container);
};

export const render = (vnode, container) => {
    patch(null, vnode, container);
};

patch函数接受三个参数,n1, n2, container,其中n1表示旧虚拟节点,n2表示新的虚拟节点,container表示父元素,在这里就是根元素app。
patch函数另外一个作用就是判断虚拟节点的type类型,有可能是div这种原生元素,也有可能是自定义组件、也有后续实现的TELEPORT组件。

伪代码:

const patch = (n1, n2, container) => {

    const { type } = n2

    switch (type) {
        case '原生元素':
            processElement(n1, n2, container);
            break;

        case '自定义组件':
            break;
        
            case 'TELEPORT':
                break

        default:
            break;
    }

    processElement(n1, n2, container);
};

processElement函数是专门用来处理原生元素的函数,这里主要处理第一次元素的挂载和diff、更新阶段。

mountElement函数主要处理元素的创建和渲染,以及后续的props、children处理。

对于原生api createElementinsertBefore 等方法,Vue单独抽离出一个库,叫runtime-dom。接下来我们也抽离出来。

runtime-dom

packages文件夹下新建runtime-dom文件夹。 其结构如下

image.png

// nodeOps.js
// 增删改查
// 增删改查
export const nodeOps = {
    // 增加、插入
    insert(child, parent, anchor) {
        // 插入子节点,也用作新增节点
        parent.insertBefore(child, anchor || null);
    },
    // 删除
    remove(child) {
        const parent = child.parentNode;
        if (parent) {
            // 移除子节点
            parent.removeChild(child);
        }
    },
    createElement(tagName) {
        // 创建普通元素
        return document.createElement(tagName);
    },
    createText(text) {
        // 创建文本节点
        return document.createTextNode(text);
    },
    createComment(data) {
        // 创建注释节点
        return document.createComment(data);
    },
    setText(el, text) {
        // 设置节点value
        el.nodeValue = text;
    },
    setElementText(el, text) {
        el.textContent = text;
    },
    parentNode(node) {
        // 返回父节点
        return node.parentNode;
    },
    nextSibling(node) {
        // 返回兄弟节点
        return node.nextSibling;
    },
    quertSelector(selector) {
        // 查询元素
        return document.querySelector(selector);
    }
};

nodeOps定义了所能用到的原生dom操作api。后续操作dom,都是调用这里的方法。

index.js文件导出所有方法。

import { nodeOps } from './nodeOps';

export { nodeOps };

每当我们新增一个包时,我们需要对根目录的package.json打包命令新增一个:如下:

  "scripts": {
    "dev:shared": "node scripts/dev.js shared -f esm-bundler",
    "dev:runtime-core": "node scripts/dev.js runtime-core -f esm-bundler",
    "dev:runtime-dom": "node scripts/dev.js runtime-dom -f esm-bundler"
  },

在引入runtime-dom之前,执行以下pnpm run dev:runtime-dom打包命令。

接着runtime-core包引入runtime-dom,执行命令

pnpm add @vue/runtime-dom --workspace

--workspace是指只从当前的项目的workspace引入。

回到renderer.js,我们重构下mountElement方法:

@vue/runtime-dom库中导入nodeOps

import { nodeOps } from '@vue/runtime-dom';
const mountElement = (vnode, container) => {
    const { type } = vnode;
    // 创建真实节点
    const el = (vnode.el = nodeOps.createElement(type));

    nodeOps.insert(el, container, null);
};

到此,我们已经完成了从h 函数 - > 创建vnode -> 根据vnode渲染真实节点整体流程。

验证下结果

执行下打包命令:

pnpm run dev:runtime-core

生成dist目录。新建测试index.html文件,编写如下代码:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>
    <body>
        <div id="app"></div>

        <script type="module">
            import { render, h } from './runtime-core.esm-bundler.js';

            render(h('div'), app);
        </script>
    </body>
</html>

通过VSCode 插件 Live Server打开index.html。我们可以看到div能够正常渲染到页面上了。

image.png

完美。

接下来,我们让他显示一些内容:

通过上面介绍的h函数我们知道,显示内容h函数可以这么写,h('div', '显示的内容') 其实就是设置下children,子元素,文本内容也可以看成子元素children。

第二个参数就是文本内容,也就是子元素。

完善下h函数

export function h(type, propsOrChildren, children, ..._) {
    const l = arguments.length;

    if (l === 2) {
        return createVNode(type, null, propsOrChildren);
    }
    return createVNode(type, propsOrChildren, children);
}

判断下参数length,如果第二个是文本,那就意味着没有属性,把propsOrChildren参数丢给createVNode函数的children参数。

mountElement函数也需要优化下:

const mountElement = (vnode, container) => {
    ...
    // 处理文本子节点
    nodeOps.setElementText(el, children);
        ...
};

此时,我们可以看到页面上已经显示出想要的内容了:

image.png

接下来我们可以给本文整点样式,class、style。 还记得我们说的h函数实现么,第二个参数可以是props:
h('div', { style: { color: 'blue' } }, '显示的内容')

回到runtime-dom,新建patchProp.js

import { patchClass } from './modules/class';

import { patchStyle } from './modules/style';
/**
 *
 * @param {*} el 真实节点信息
 * @param {*} key props对应的key
 * @param {*} preValue 上一个值
 * @param {*} nextValue 新值
 *
 * preValue 和 nextValue主要用于diff阶段的判断
 */
export const patchProp = (el, key, preValue, nextValue) => {
    if (key === 'class') {
        patchClass(el, nextValue);
    } else if (key === 'style') {
        patchStyle(el, preValue, nextValue);
    }
};

实现patchClass patchStyle方法:

// patchClass.js
export function patchClass(el, value) {
    if (value === undefined || value === null) {
        // 直接覆盖
        el.className = value;
    } else {
        // 删除class属性
        el.removeAttribute('class');
    }
}
// patchStyle.js
export function patchStyle(el, prev, next) {
    const style = el.style;
    const isCssString = typeof next === 'string';
    const isPrevCssString = typeof prev === 'string';

    // eg. <div :style={}></div> style是个对象

    // 存在新值,并且是对象
    if (next && !isCssString) {
        if (prev && !isPrevCssString) {
            // 说明老值 和新值都是对象
            for (const key in prev) {
                // 老值 在新的值里面没有,删除老值
                if (next[key] == null) {
                    style[key] = '';
                }
            }
        }

        // 设置新的值
        for (const key in next) {
            style[key] = next[key];
        }
    } else {
        // eg. <div style=""></div>
        // 新值是string
        if (isCssString) {
            // 比较下是否相等
            if (prev != next) {
                style.cssText = next;
            }
        } else if (prev) {
            // 新值是null,而且存在老值,删掉老值
            el.removeAttribute('style');
        }
    }
}

逻辑还是比较简单,class属性直接赋值、删除就行,style处理稍微多一点,要考虑到新老值之间的变化,老值在新值中不存在就删除,(不能完全删除,因为新值有可能只替换了老值的部分属性) ,然后设置新值。如果是字符串style,直接比较,不同直接替换就行,最后,如果新值是null,直接removeAttribute('style')就可以了。

重构下导出文件:将nodeOps和patchProp合并下。

import { patchProp } from './patchProp';
const rendererOptions = Object.assign(nodeOps, { patchProp });
export { rendererOptions };

回到mountElement方法处理下props:

const mountElement = (vnode, container) => {
    ...
    if (props) {
        for (const key in props) {
            nodeOps.patchProp(el, key, null, props[key]);
        }
    }

    ...
};

看下效果:

image.png

完美。

总结下:

  • 从0搭建pnpm项目
  • 从最简单的需求,实现h函数 -> createVNode -> 元素的渲染 -> 元素属性的处理

后续还有元素的事件处理、实现内部Text组件、Common组件、自定义组件渲染、diff流程等等。还有很多内容要做。如果能帮到你,欢迎关注。如果哪里写的不对的地方,评论区我们可以友好的交流、讨论。


楠则
1 声望0 粉丝