vuejs设计与实现1-3
Vue
1. 权衡的艺术
2. 框架设计的核心要素
3. vue.js3设计思路
1. 权衡的艺术
- 框架设计:在保持可维护性的同时让性能损失最小化;
命令式 VS 声明式
- 从范式上来看,视图层框架分为命令式和声明式。
- 命令式框架:关注过程,性能优; 声明式框架:关注结果,可维护性好
- 框架设计需要考虑可维护性和性能之间的平衡,在保持可维护性的同时让性能损失最小化;
- 对于框架来说,为了实现最优的更新性能,需要找到前后的差异并只更新变化的地方;但最终完成更新的代码仍是
div.textContent = 'hello'
- 声明式代码会比命令式代码多出找出差异的性能消耗;最理想的情况是,当找出差异的性能消耗为0,声明式代码与命令式代码的性能相同,但无法超越,框架本身就是封装了命令式代码才实现面向用户的声明式。
- vuejs选择声明式设计方案的原因:声明式代码可维护性更强。在采用命令式代码时候,我们需要维护实现目标的整个过程,包括手动完成DOM元素的创建、更新、删除等工作;而声明式代码展示的是我们要的结果,看上去更直观,做事的过程vue内部实现。
- 声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗;
虚拟DOM
- 虚拟DOM的出现,就是为了最小化找出差异的性能消耗,
innerHTML操作页面 VS 虚拟DOM操作页面
- 创建页面
- (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) 使用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元素),可维护性差,性能高;
运行时和编译时
- 运行时框架,直接为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);
- 运行时+编译时
- 怎么用类似HTML标签的方式描述树形结构呢?引入编译手段,把HTML标签编译成树形结构的数据对象,则可以继续使用Render函数
- 编译过程,可以分析用户提供的内容,看看哪些内容未来会改变,哪些内容永远不会改变,就可以在编译时提取这些信息,然后将其传递给Render函数,Render函数得到这些信息就可以做进一步的优化;
const html = `
<div>
<span>hello world</span>
</div>
`
// 调用compiler编译得到树形结构,把HTML字符串编译成数据对象
const obj = compiler(html)
// 调用render进行渲染
Render(obj, document.body)
- 编译时
- 把HTML字符串编译成命令式代码
- 纯编译时,不需要任何运行时,而是直接编译成可执行JavaScript代码,性能可能更好,但不灵活,用户提供的内容必须编译后才能用;
const div = document.createElement('div');
const span = document.createElement('span');
span.innerText = 'hello world';
div.appendChild(span);
document.body.appendChild(div);
2. 框架设计的核心要素
- 提升用户的开发体验,增加警告信息
- 控制框架代码的体积,开发环境为用户提供友好的警告信息
if (__DEV__) {
console.log('1111');
}
- 框架Tree-Shaking
- 消除那些永远不会被执行的代码;如果一个函数调用会产生副作用(当调用函数的时候会对外部产生影响如修改了全局变量),那么即使它没被执行也不会被移除; /#PURE/,作用就是告诉rollup.js,对于foo函数的调用不会产生副作用,可以对其进行Tree-Shaking;
import {foo} from "./utils";
/*#__PURE__*/ foo()
- Tree-Shaking必须满足一个条件,即模块必须是ESM(ES Module),因为Tree-Shaking依赖ESM的静态结构;
- 框架应该输出什么样的构建产物
- 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"
}
- 特性开关
- 对于用户关闭的特性,可以利用Tree-Shaking机制让其不包含在最终的资源中;
- 该特性为框架设计带来了灵活性,可以通过特性开关任意为框架添加新的特性;
- 怎么实现特性开关呢?原理同__DEV__常量一样,本质上是利用rollup的预定义常量插件来实现
__VUE__OPTIONS__API__
- 框架内置错误处理
// vuejs中可以注册统一的错误处理函数
import App from 'App.vue';
const app = createApp(App);
app.config.errorHandler = () => {
// 错误处理程序
}
- 良好的TS支持
- 使用TS编写框架和框架对TS类型支持友好是两件不同的事;
3. vue.js设计思路
- 声明式的描述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}
}
}
}
- 渲染器
- 虚拟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'
}
- 组件的本质
- 虚拟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)
}
- 模板的工作原理
- 模板工作原理: 无论是使用模板还是手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟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设计与实现-霍春阳
宝剑锋从磨砺出,梅花香自苦寒来。