【vue设计与实现】框架设计相关知识

框架设计相关知识

(进一步精简概念化)
从范式来看,视图层框架通常分为命令式和声明式

命令式
命令式框架的一大特点就是关注过程,之前jQuery就是典型的命令式框架。例如下面的代码:

$('#app') //获取div
	.text('hello world') //设置文本内容
		.on('click', ()=>{alert('ok')})  // 绑定点击事件

即代码描述的是“做事的过程”

声明式
声明式框架更加关注结果。结合Vue.js, 来看下面的示例:

<div @click="() => alert('ok')"> hello world</div>

Vue.js的内部实现是命令式的,暴露给用户的却更加声明式,因为Vue.js封装了过程,我们只用关心结果

性能与可维护性的权衡
首先声明式代码的性能不优于命令式代码
理论上命令式代码可以做到极致的性能优化,因为我们明确知道哪些发生了变更,只做必要的修改就行了。但是声明式代码不一定能做到这一点,因为它描述的是结果。
声明式代码比命令式代码多出了找出差异的性能消耗。
但是声明式代码的可维护性更强。声明式代码展示的就是我们要的结果,看上去更加直观。因此框架设计者在权衡的时候要考虑的是:在保持可维护性的同时让性能损失最小化

虚拟DOM
前面提到 声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗,如果能够最小化找出差异的性能消耗,就能让声明式代码的性能无限接近命令式代码的性能。所谓的虚拟DOM,就是为了最小化找出差异这一步的性能消耗
虚拟DOM保证应用程序的性能下限,让应用程序的性能不至于太差,甚至逼近命令式代码的性能。
虚拟DOM创建页面的过程可以分为两步:
第一步:创建JavaScript对象,这个对象可以理解为对真实DOM的描述
第二步:递归地遍历虚拟DOM树并创建真实DOM

在更新页面时,虚拟DOM在javaScript层面的运算要比创建页面时多了Diff的性能消耗,但是这也是在javaScript层面上的运算,不会产生数量级的差异。虚拟DOM在更新页面时只会更新必要的元素,但innerHTML需要全量更新。这就是虚拟DOM有优势的地方。
并且使用原生DOM操作心智负担大,可维护性差,比如在拼接HTML字符串时,阅读难度大。使用虚拟DOM,因为其是声明式的,所以心智负担小,可维护性强,性能虽比不上极致优化的原生JavaScript,但在保证心智负担和可维护性的前提下相当不错

运行时和编译时
设计一个框架时,有三种选择:纯运行时,运行时+编译时,纯编译时。
首先要了解这些概念:
纯运行时
假设我们设计了一个框架,它提供了一个Render函数,用户可以为该函数提供一个树形结构的数据对象,然后Render函数会根据该对象递归地将数据渲染成DOM元素,例如:

//树型数据例子
const obj = {
	tag: 'div',
	children:[
		{
			tag:'span',
			children: 'hello world'
		}
	]
}
//Render函数例子
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) {
    // array,递归调用 Render,使用 el 作为 root 参数
		obj.children.forEach((child) => Render(child, el))
  }

  // 将元素添加到 root
  root.appendChild(el)
}
//使用例子
// 渲染到 body 下
Render(obj, document.body)

在这里可以看到,用户在使用Render函数来渲染内容时,直接为Render函数提供了一个树型结构的数据对象,这里面不涉及任何额外的操作,那么能不能支持用类似于HTML标签的方式描述树型结构的数据对象?不行,暂不支持。这样的框架就是一个纯运行时的框架。
总结:
纯运行时的框架:为Render函数提供了一个树型结构的数据对象,无编译

如果引入编译的手段,把HTML标签编译成树型结构的数据对象,为此需要Compiler的程序,其作用就是把HTML字符串编译成树型结构的数据对象,例如:

const html=`<div><span>hello world</span></div>`
// 调用Compiler 编译得到树型结构的数据对象
const obj = Compiler(html);
Render(obj, document.body)

这样框架就变成了 运行时+编译时的框架。准确的来说,上面的代码其实是运行时编译,意思是代码运行的时候才开始编译,而会产生一定的性能开销。
总结:
运行时+编译时的框架:为Render函数提供了编译后的数据对象

既然编译器可以把HTML字符串编译成数据对象,那么能不能直接编译成命令式代码呢。这样只需要一个Compiler函数就可以了。这样就变成了一个纯编译的框架,因为不支持任何运行时的内容,用户的代码通过编译器编译后才能运行。
总结:
纯编译的框架:编译器把HTML字符串编译成命令式代码

至此可以看到,一个框架既可以是纯运行时的,也可以是纯编译时的,还可以是既支持运行时有支持编译时的。那么他们都各有什么优缺点呢?
纯运行时的框架,因为没有编译过程,所以无法分析用户提供的内容,但是如果加入了编译步骤,就可以分析了,看看哪些内容未来可能会改变,哪些内容永远不会变,在得到这些信息后,就可以进一步进行优化了。
如果设计的框架是纯编译时的,也可以分析用户提供的内容,由于不需要任何运行,而是直接编译成可执行的JavaScript代码,因此性能可能会更好,但是这种做法有损灵活性,即用户提供的内容必须编译后才能使用。Vue.js3任然保持了运行时+编译时的架构,在保持灵活性的基础上能够尽可能地去优化。

框架设计的核心要素

框架设计要注意:框架应该给用户提供哪些构建产物;产物的模块格式如何;开发版本的构建和生产版本的构建有什么区别;热更新要不要考虑;用户能否选择关闭其他功能从而减少最终资源的打包体积

提升用户的开发体验
在框架设计和开发过程中,提供友好的警告信息至关重要。始终提供友好的警告信息不仅能够帮助用户快速定位问题,还能够让框架收获良好的口碑。
在Vue.js中,经常可以看到warn函数的调用,例如:

warn{
	`Failed to mount app: mount target selector "${container}" retured null,`
}

对于warn函数来说,由于其需要尽可能提供有用的信息,因此需要手收集当前发生错误的组件栈信息,所以有些复杂,但是最终就是调用了console.warn函数

控制框架代码的体积
框架的大小也是衡量框架的标准之一。在实现同样功能的情况下,用的代码越少越好。但提供完善的警告信息就意味着要编写更多的代码,这和代码越少越好的观念是冲突的,所以我们要想办法解决这个问题。
在Vue.js3的源码中,会发现每一个warn函数的调用都会配合__DEV__常量的检查,例如:

if(__DEV__&& !res){
	warn{
		`Failed to mount app: mount target selector "${container}" retured null,`
	}
}

可以看到打印警告信息的前提是:__DEV__为true
Vue.js在输出资源的时候,会输出两个版本,其中一个用于开发环境,如vue.global.js;一个用于生产环境,如vue.global.prod.js
用开发环境时,会把__DEV__设置为true,用于生产环境时,会把__DEV__设置为false,这时有些分支代码永远不会执行,就比如上面的warn,这样就做到了 在开发环境中为用户提供友好的警告信息的同时,不会增加生产环境代码的体积

框架要做到良好的Tree-Shaking

什么事Tree-Shaking?在前端领域,这个概念因rollup.js而普及。简单来说,Tree-Shaking指的就是消除那些永远不会被执行的代码,就是排除dead code。
要实现Tree-Shaking,必须满足一个条件,即模块必须是ESM(ES Module)(ESM相关知识 https://zhuanlan.zhihu.com/p/400573436),
我们以rollup.js为例看看Tree-Shanking如何工作的
目录结构如下:

demo
	packag.json
	input.js
	utils.js

首先安装rollup.js

yarn add rollup -D
或者 npm install rollup -D

下面是input.js和utils.js文件的内容

//input.js
import { foo } from './utils.js'
foo()

//utils.js
export function foo(obj){
	obj && obj.foo
}
export function bar(obj){
	obj && obj.bar
}

可以看到在input.js中导入了foo函数并执行,注意,这里并没有导入bar函数
接着,用如下命令进行构建

npx rollup input.js -f esm -o bundle.js

这句命令的意思是,以input.js文件为入口,输出ESM,输出的文件叫作bundle.js。命令成功后打开bundle.js可以看到如下内容:

// bundle.js
function foo(obj){
	obj && obj.foo
}
foo();

可以看到这里面并不包含bar函数,这说明Tree-Shaking起了作用。但是仔细观察可以发现,foo函数的执行也没有什么意义,就是读取了对象的值,那为什么rollup.js没有把foo看作是dead code移除掉呢?
这就涉及Tree-Shaking中的第二个关键点——副作用。如果一个函数调用会产生副作用,那么久不能将其移除。那什么是副作用?副作用就是,当调用函数的时候会对外部产生影响,就比如修改全局变量。但是上面的代码明显是读取对象的值,怎么会产生副作用?其实是有可能的

注意注释代码/*#__PURE__*/,其作用就是告诉rollup.js,对于foo函数的调用不会产生副作用,可以放心地对其进行Tree-Shaking,此时再次执行构建命令并查看bundle.js文件,就会发现其内容是空的,这说明Tree-Shaking生效了。
因而,在编写框架时要合理使用/*#__PURE__*/注释,在Vue.js3的源码中,会发现大量使用了该注释。那么大量使用这个注释会不会对编写代码造成很大的心智负担?其实不会,因为通常产生副作用的代码都是模块内函数的顶级调用,关于顶级调用可以看下面的示例:

foo() //顶级调用

function bar(){
	foo() //函数内调用
}

可以看到对于函数内调用来说,只要函数bar没有被调用,那么foo函数的调用自然不会产生副作用。该注释不仅仅作用于函数,它可以应用于任何语句上。该注释也不是只有rollup.js才能识别,webpack以及压缩工具(如terser)都能识别。

框架应该输出怎么的构建产物
Vue.js会为开发环境和生产环境输出不同的包,实际上,Vue.js的构建产物除了有环境上的区分之外,还会根据使用场景的不同而输出其他形式的产物
不同类型的产物一定有对应的需求背景,那么久先从需求讲起,首先我们希望用户可以直接在HTML页面中使用<script>标签引入框架并使用。
为实现这个需求,需要输出一种名为IIFE(Immediately Invoked Function Expression)样式的资源,IIFE即“立即调用的函数表达式”,易于用JavaScript来表达:

(function(){
	// ...
}())

上面的代码是一个立即执行的函数表达式。
实际上,vue.global.js文件就是IIFE形式的资源,其代码结构如下:

var Vue = (function(exports){
	// ...
	exports.createApp = createAppl
	// ...
	return exports
}(()))

这样使用<script>标签直接引入vue.global.js文件后,全局变量Vue就是可用的了。

特性开关
在设计框架时,框架会为用户提供诸多特性(或功能),例如我们提供A,B,C三个特性给用户,同时还提供了a,b,c三个对应的特性开关,用户可以通过设置a,b,c为true或false来代表开启或关闭对应的特性,这会带来很多好处:

  • 对于用户关闭的特性,可以利用Tree-Shaking机制让其不包含在最终的资源中
  • 为框架设计带来了灵活性,可以通过特性开关任意为框架添加新的特性,也可以在框架升级时通过特性开关来支持遗留API,这样新用户可以选择不使用遗留API

那怎么实现特性开关?其实很简单,原理和尚未提到的__DEV__常量一样,本质上市利用rollup.js的预定义常量插件来实现。如下:

{
	__FEATURE_OPTIONS_API_:isBundlerESMBuild ? `__VUE_OPTIONS_API__`:true
}

其中__VUE_OPTIONS_API__类似于__DEV__

错误处理
错误处理是框架开发过程中非常重要的环节。我们要为用户提供统一的错误处理接口,如下面代码:

// utils.js
let handleError = null
export default {
	foo(fn){
		callWithErrorHandling(fn)
	},
	// 用户可以调用该函数注册统一的错误处理函数
	registerErrorHandler(fn){
		handleError = fn
	}
}
function callWithErrorHandling(fn){
	try{
		fn && fn()
	}catch (e) {
		//将捕获到的错误传递给用户的错误处理程序
		handleError(e)
	}
}

我们提供了registerErrorHandler函数,用户可以使用它注册错误处理程序,然后再callWithErrorHandling函数内部捕获错误后,把错误传递给用户注册的错误处理程序。
这样用户侧的代码就会非常简洁且健壮

import utils from 'utils.js'
//注册作物处理程序
utils.registerErrorHandler((e)=>{
	console.log(e)
})
utils.foo(()=>{/*...*/})
utils.bar(()=>{/*...*/})

这时错误处理的能力完全由用户控制,用户既可以选择忽略错误,也可以调用上报程序将错误上报给监控系统。
这就是Vue.js错误处理的原理,可以在源码中搜索到callWithErrorHandling函数。另外,在Vue.js中,也可以注册统一的错误处理函数:

import App from 'App.vue'
const app = createApp(App)
app.config.errorHandler = () => {
	// 错误处理程序
} 
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值