vuejs设计与实现1-3

Vue

1. 权衡的艺术

2. 框架设计的核心要素

3. vue.js3设计思路

1. 权衡的艺术

  • 框架设计:在保持可维护性的同时让性能损失最小化;
命令式 VS 声明式
  1. 从范式上来看,视图层框架分为命令式和声明式。
  2. 命令式框架:关注过程,性能优; 声明式框架:关注结果,可维护性好
  3. 框架设计需要考虑可维护性和性能之间的平衡,在保持可维护性的同时让性能损失最小化;
  4. 对于框架来说,为了实现最优的更新性能,需要找到前后的差异并只更新变化的地方;但最终完成更新的代码仍是div.textContent = 'hello'
  5. 声明式代码会比命令式代码多出找出差异的性能消耗;最理想的情况是,当找出差异的性能消耗为0,声明式代码与命令式代码的性能相同,但无法超越,框架本身就是封装了命令式代码才实现面向用户的声明式。
  6. vuejs选择声明式设计方案的原因:声明式代码可维护性更强。在采用命令式代码时候,我们需要维护实现目标的整个过程,包括手动完成DOM元素的创建、更新、删除等工作;而声明式代码展示的是我们要的结果,看上去更直观,做事的过程vue内部实现。
  7. 声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗;

虚拟DOM
  • 虚拟DOM的出现,就是为了最小化找出差异的性能消耗,
innerHTML操作页面 VS 虚拟DOM操作页面
  1. 创建页面
  • (1) 通过innerHTML创建页面性能:HTML字符串拼接计算量 + innerHTML的DOM计算量;(首先将字符串解析为DOM树,DOM层面的计算,性能比JavaScript层面的计算性能差;)
const html = `<div><span>....</span></div>`;
div.innerHTML = html;
  • (2) 虚拟DOM创建页面性能:创建JavaScript对象的计算量 + 创建真实DOM的计算量;(第一步,创建JavaScript对象,即真实DOM的描述;第二步,递归地遍历虚拟DOM树并创建真实DOM;)
  1. 更新页面
  • (1) 使用innerHTML更新页面过程:1. 重新构建HTML字符串;2. 重新设置DOM元素的innerHTML属性(销毁所有的旧DOM元素,再重新创建新的DOM元素);
  • (2) 虚拟DOM更新更新页面过程:1. 重新渲染JavaScript对象(虚拟DOM树);2. 比较新旧虚拟DOM Diff,找到变化的元素并更新它;
  • ps:在更新页面时,虚拟DOM在JavaScript层面的运算要比创建页面时多出一个Diff的性能消耗,然而毕竟是JavaScript层面的运算,不会产生数量级的差异;再观察DOM层面的运算,可以发现虚拟DOM在更新页面时只会更新必要的元素,但innerHTML需要全量更新。
  • innerHTML、虚拟DOM及原生JavaScript(指createElement等方法)在更新页面时的性能对比:(1) innerHTML(模板),心智负担中等,性能差;(2) 虚拟DOM,心智负担小,可维护性强,性能不错; (3) 原生JavaScript,心智负担大(手动创建、删除、修改大量的DOM元素),可维护性差,性能高;

运行时和编译时
  1. 运行时框架,直接为Render函数提供了一个树形结构的数据对象,没有编译的过程,没办法分析用户提供的内容;
// 树形数据结构对象
const obj = {
    tag: 'div',
    children: [
        {
            tag: 'span',
            children: 'hello world'
        }
    ]
}

// 渲染函数
function Render(obj, root) {
    const el = document.createElement(obj.tag);
    if (typeof obj.children === 'string') {
        const text = document.createTextNode(obj.children);
        el.appendChild(text);
    } else if (obj.children) {
        // 数组,递归调用Render,使用el作为root元素
        obj.children.forEach((child) => Render(child, el))
    }

    // 将元素添加到root
    root.appendChild(el);
}

// 渲染到body下
Render(obj, document.body);
  1. 运行时+编译时
  • 怎么用类似HTML标签的方式描述树形结构呢?引入编译手段,把HTML标签编译成树形结构的数据对象,则可以继续使用Render函数
  • 编译过程,可以分析用户提供的内容,看看哪些内容未来会改变,哪些内容永远不会改变,就可以在编译时提取这些信息,然后将其传递给Render函数,Render函数得到这些信息就可以做进一步的优化;
const html = `
    <div>
        <span>hello world</span>
    </div>
`

// 调用compiler编译得到树形结构,把HTML字符串编译成数据对象
const obj = compiler(html)

// 调用render进行渲染
Render(obj, document.body)
  1. 编译时
  • 把HTML字符串编译成命令式代码
  • 纯编译时,不需要任何运行时,而是直接编译成可执行JavaScript代码,性能可能更好,但不灵活,用户提供的内容必须编译后才能用;
const div = document.createElement('div');
const span = document.createElement('span');
span.innerText = 'hello world';
div.appendChild(span);
document.body.appendChild(div);


2. 框架设计的核心要素

  1. 提升用户的开发体验,增加警告信息
  2. 控制框架代码的体积,开发环境为用户提供友好的警告信息
if (__DEV__) {
    console.log('1111');
}
  1. 框架Tree-Shaking
  • 消除那些永远不会被执行的代码;如果一个函数调用会产生副作用(当调用函数的时候会对外部产生影响如修改了全局变量),那么即使它没被执行也不会被移除; /#PURE/,作用就是告诉rollup.js,对于foo函数的调用不会产生副作用,可以对其进行Tree-Shaking;
import {foo} from "./utils";
/*#__PURE__*/ foo()
  • Tree-Shaking必须满足一个条件,即模块必须是ESM(ES Module),因为Tree-Shaking依赖ESM的静态结构;
  1. 框架应该输出什么样的构建产物
  • vue.js会为开发环境和生产环境输出不同的包,如vue.global.js用于开发环境,包含必要的警告信息,vue.global.prod.js用于生产环境;
  • vuejs还会根据使用场景的不同而输出其他形式的产物:(1) 在HTML页面中使用<script src="/vue.global.js">标签引入框架并使用,需要输出一种叫做IIFE格式的资源(立即调用的函数表达式);rollup中通过配置format:'iife'来输出这种形式的资源; (2) 直接引入ESM格式的资源<script type="module" src="/path/to/vue.esm-browser.js"></script>; (3) Node.js中引入vueconst vue = require('vue'), 服务端渲染,Nodejs环境中,资源的模块格式是cjs,rollup.config.js的配置format:'cjs'
// vue.esm-browser.js, vue.runtime.esm-bundler.js
// 带有-bundler字样的ESM资源是给rollup或webpack等打包工具使用的;-browser字样的ESM资源是直接给`<script type="module"></script>`使用的;const __DEV__ = process.env.NODE_ENV !== 'production'
// 在寻找资源时,如果pageage.json中存在module字段,会优先使用module字段指向的资源来代替main字段指向的资源。
{
    "main": "index.js",
    "module": "dist/vue.runtime.esm-bundler.js"
}
  1. 特性开关
  • 对于用户关闭的特性,可以利用Tree-Shaking机制让其不包含在最终的资源中;
  • 该特性为框架设计带来了灵活性,可以通过特性开关任意为框架添加新的特性;
  • 怎么实现特性开关呢?原理同__DEV__常量一样,本质上是利用rollup的预定义常量插件来实现__VUE__OPTIONS__API__
  1. 框架内置错误处理
// vuejs中可以注册统一的错误处理函数
import App from 'App.vue';
const app = createApp(App);
app.config.errorHandler = () => {
    // 错误处理程序
}
  1. 良好的TS支持
  • 使用TS编写框架和框架对TS类型支持友好是两件不同的事;


3. vue.js设计思路

  1. 声明式的描述UI
  • (1) 编写前端页面都涉及哪些内容:1. DOM元素:如div还是a;2. 属性:如a的href属性,id、class属性;3. 事件:如click、keydown;4. 元素的层级结构:如DOM树的层级结构,既有子节点也有父节点;
  • (2) 如何声明式地描述上面内推呢,vuejs解决方案:1. 使用与HTML标签一致的方式来描述DOM元素如<div id="app"></div>; 2. 使用与HTML标签一致的方式来描述属性,如<div id="app"></div>; 3. 使用:或v-bind来描述动态绑定的属性如<div :id="dynamicId"></div>; 4. 使用@或v-on来描述事件如点击事件<div @click="handler"></div>; 5. 使用与HTML标签一致的样式来描述层级结构;如<div><span></span></div>; ps: 除了使用模块来声明式地描述UI外,还能使用JavaScript对象来描述
const title = {
    tag: 'h1',
    props: {
        onClick: handler,
    },
    children: [
        {tag: 'span'}
    ]
}

// 等价于
<h1 @click="handler"><span></span></h1>

// h函数的返回值就是一个对象,其作用是让编写虚拟DOM更轻松
import {h} from "vue";
export default {
    render() {
        return h('h1', {onClick: handler})
    }
}

// 组件的渲染函数,一个组件要渲染的内容是通过渲染函数来描述的,即render函数,vuejs会根据组件的render函数的返回值拿到虚拟DOM,然后就可以把组件的内容渲染出来了;

export default {
    render() {
        return {
            tag: 'h1',
            props: {onClick: handler}
        }
    }
}

  1. 渲染器
  • 虚拟DOM:用JavaScript对象来描述真实的DOM结构
  • 渲染器:把虚拟DOM转为真实DOM;vuejs组件都是依赖渲染器来工作的;
const vnode = {
    tag: 'div',
    props: {
        onClick: () => alert('hello');
    },
    children: 'click me'
}
// vnode虚拟DOM对象;container一个真实DOM元素,作为挂载点,渲染器会把虚拟DOM渲染到该挂载点下;
function renderer(vnode, container) {
    // 创建元素
    const el = document.createElement(vnode.tag);
    // 为元素添加事件和属性
    for(const key in vnode.props) {
        if (/^on/.test(key)) {
            el.addEventListener(
                key.substr(2).toLowerCase(),
                vnode.props[key]
            )
        }
    }
// 处理children
    if (typeof node.children === 'string') {
        el.appendChild(document.createTextNode(vnode.children))
    } else if(Array.isArray(vnode.children)) {
        vnode.children.forEach(child => renderer(child, el))
    }

    container.appendChild(el)
}

renderer(vnode, document.body)
  • 上面还仅仅是创建节点,渲染器的精髓在于更新节点的阶段;对于渲染器来说,需要精确地找到vnode对象的变更点并且只更新变更点的内容;如下children变化,渲染器应该只更新元素的文本内容,而不需要再走一遍完整的创建元素的流程;归根结底,都是使用一些DOM API来完成渲染工作;
const vnode = {
    tag: 'div',
    props: {
        onClick: () => alert('hello')
    },
    children: 'click again'
}
  1. 组件的本质
  • 虚拟DOM除了能够描述真实DOM外,还能描述组件;
  • 组件是一组DOM元素的封装,这组DOM元素就是组件要渲染的内容;因此可以定义一个函数来代表组件,而函数的返回值就是组件要渲染的内容;
// 组件的返回值也是虚拟DOM,它代表组件要渲染的内容
const MyComp = function () {
    return {
        tag: 'div',
        props: {
            onClick: () => alert('hello')
        },
        children: 'click me'
    }
}

const vnode = {
    tag: MyComp, // 用来描述组件
}

// 渲染元素
function renderer(vnode, container) {
    if (typeof vnode.tag === 'string') {
        mountElement(vnode, container)
    // } else if (typeof vnode.tag === 'function') { // 组件是函数,返回虚拟DOM
    } else if (typeof vnode.tag === 'obejct') {  // 组件是对象
        mountComponent(vnode, container)
    }
}

function mountElement(vnode, container) {
    // 使用vnode.tag作为标签名创建DOM元素
    const el = document.createElement(vnode.tag);
    // 遍历vnode.props,将属性、事件添加到DOM元素
    for(const key in vnode.props) {
        if (/^on/.test(key)) {
            el.addEventListener(
                key.substr(2).toLowerCase(),
                vnode.props[key]
            )
        }
    }
// 处理children
    if (typeof children === 'string') {
        el.appendChild(document.createTextNode(vnode.children))
    } else if(Array.isArray(vnode.children)) {
        vnode.children.forEach(child => renderer(child, el))
    }
// 将元素添加到挂载节点下
    container.appendChild(el)
} 


// 渲染组件
function mountComponent(vnode, container) {
    // const subtree = vnode.tag; // 组件是函数,返回虚拟DOM
    const subtree = vnode.tag.render();  // 组件是对象,调用它的render函数得到组件要渲染的内容

    renderer(subtree, container)
}

  1. 模板的工作原理
  • 模板工作原理: 无论是使用模板还是手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟DOM渲染为真实的DOM
  • 编译器:将模板编译为渲染函数;
  • template标签里面的内容就是模板内容,编译器会将模板内容编译成渲染函数并添加到script标签块的组件对象上
<template>
    <dov @click="handler">click me</dov>
</template>
// 最终浏览器里运行的代码
export default {
    data() {},
    methods: {
        handler: () => {}
    },
    render() {
        return h('div', {onClick:handler}, 'click me')
    }
}


// 无论是使用模板还是手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟DOM渲染为真实的DOM,这就是模板工作原理,也是vuejs渲染页面的流程。

// 组件的实现依赖渲染器;模板的编译依赖于编译器;并且编译后生成的代码是根据渲染器和虚拟DOM设计决定的,因此vuejs的各个模块之间是相互关联、互相制约的,共同构成一个整体。

// 渲染器的作用:寻找并且只更新变化的内容;编译器的作用:分析动态内容,并且在编译阶段把这些信息提取出来,然后交给渲染器,则渲染器就不必花费大力气去寻找变更点了。
render() {
    return {
        tag: 'div',
        props: {
            id: 'foo',
            class: cls
        },
        patchFlags: 1, // 假设1代表是动态的,这样渲染器看到这个标志就知道这里属性会发生变化
    }
}






参考&感谢各路大神

1. vue.js设计与实现-霍春阳

posted @ 2024-07-23 15:05  安静的嘶吼  阅读(1)  评论(0编辑  收藏  举报