前端 Vue 设计与实现 TheStoneFish 2025-04-18 2025-04-24 Vue 设计与实现 一、平衡的艺术 1、命令式与声明式 命令式与声明式的定义
命令式编程 :关注过程 ,开发者手动控制每一步操作。
声明式编程 :关注结果 ,开发者只需声明期望的结果,框架(如 Vue)负责过程。
命令式和声明式的对比 示例:如何实现以下需求?
获取 id 为 app
的 div 标签
将文本内容设为 hello world
绑定点击事件,点击时弹出提示框
命令式实现 :
1 2 3 const div = document .querySelector ('#app' );div.innerText = 'hello world' ; div.addEventListener ('click' , () => alert ('ok' ) );
声明式实现 :
1 <div id="app" @click="() => alert('ok')">hello world</div>
在声明式编程中,我们只关心结果,如何实现由 Vue 来处理。
2、性能与可维护性的权衡 性能对比 命令式和声明式各有优缺点,尤其在性能方面,声明式代码通常会稍微比命令式差 ,原因如下:
示例:更新 div
内容为 hello vue3
1 div.innerText = 'hello vue3' ;
1 2 3 <div @click="() => alert('ok')">hello world</div> ↓ <div @click="() => alert('ok')">hello vue3</div>
命令式:直接操作 DOM,性能开销较小。 声明式:需要计算差异(diff )并更新,性能开销较大。
可维护性对比
声明式代码 :关注“做什么”,框架处理底层细节,代码简洁且易于理解与扩展。
命令式代码 :需要手动控制每一个操作,随着项目复杂度增加,维护难度也增加。
声明式更适合团队协作和大型项目开发,提升可维护性。
开发效率对比 声明式编程相对命令式编程,在开发效率上具有明显优势 ,因为开发者不需要处理大量细节,框架自动完成工作。
示例:创建多个 div
元素,内容为 1, 2, 3, 4
命令式实现 :
1 2 3 4 5 6 7 const container = document .getElementById ('app' );const values = [1 , 2 , 3 , 4 ];for (let i = 0 ; i < values.length ; i++) { const div = document .createElement ('div' ); div.textContent = values[i]; container.appendChild (div); }
声明式实现 :
1 2 3 <div v-for="i in [1, 2, 3, 4]" :key="i"> {{ i }} </div>
在声明式实现中,我们关注的是数据和模板,而 Vue 会自动帮我们创建 div
元素。
3、虚拟 DOM 的性能 虚拟 DOM 的主要作用是优化“查找差异”的性能消耗。与直接操作 DOM 或使用 innerHTML
比较,虚拟 DOM 能够大幅减少重绘和重排的性能开销。
innerHTML
的性能问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <!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 > <script > let html = '' ; for (let i = 0 ; i < 10000 ; i++) { html += `<div>${i} </div>` ; } document .body .innerHTML = html; </script > </body > </html >
使用 innerHTML
的性能开销 :
步骤 :
局部 HTML 重新解析
重新生成 DOM 节点
重新生成 Layout 树(Reflow)
分层(Layer Tree)
分块(Tiling)
光栅化(Rasterization)
绘制(Paint)
直接使用 JavaScript 操作 DOM :
步骤 :
直接操作已有的 DOM
重新生成 Layout 树(Reflow)
分层
分块
光栅化
绘制
使用虚拟 DOM :
步骤 :
更改更新的 DOM
查找需要更新的 DOM(diff)
更新真实 DOM
重新生成 Layout 树(Reflow)
分层
分块
光栅化
绘制
innerHTML需要解析HTMl, 并且它会摧毁之前的dom, 构建新的dom。
Javascript只需要更新指定的元素。
虚拟Dom通过diff查找需要更新的元素指定更新。
性能排序 : innerHTML < 虚拟 DOM < 原生 JavaScript
运行时与编译时 编译时 和 运行时 的区别运行时
运行时的代码在浏览器中直接运行,框架本身在代码执行时提供方法、生命周期、响应式等功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 <!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 id ="app" > <script > 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 ) { obj.children .forEach ((child ) => Render (child, el)); } root.appendChild (el); } const root = document .getElementById ('app' ); Render (obj, root); </script > </body > </html >
编译时
编译时框架会在代码执行前,将模板字符串转换成原生 JavaScript 代码,框架本身不参与执行。
编译过程:模板 → JavaScript 渲染函数。
1 2 3 <div > <span > hello world</span > </div >
编译后 :
1 2 3 4 5 6 const obj = { tag : 'div' , children : [ { tag : 'span' , children : 'hello world' } ] };
在之前的运行时的基础上加上编译时
。
编译一个函数Compiler来实现将字符串转为需要的对象
1 2 3 4 5 6 7 8 const html = ` <div> <span>hello world</span> </div>` const root = document .getElementById ('app' );const obj = Compiler (html);Render (obj, root);
这就是 Vue 的编译时 + 运行时 框架。
纯编译时框架
直接将 HTML 转换成原生 JavaScript:
1 2 3 <div > <span > hello world</span > </div >
编译后 :
1 2 3 4 5 const div = document .createElement ('div' );const span = document .createElement ('span' );span.innerText = 'hello world' ; div.appendChild (span); document .body .appendChild (div);
二、框架设计的核心要素 1、提升用户的开发体验 1 create(App).mount('not-exist');
在vue中挂载一个不存在的元素的时候会收到一条
[Vue warn]: Failed to mount app: mount target selector “#not-exist” returned null.
用于告诉我们挂载失败了, 并说明了失败的原因, 这是Vue内部经过处理后返回的失败, 如果是原生的js的报错对于定位问题是很不友好的, 所以框架设计和开发的过程中, 提供友好的警告信息至关重要。
2、控制框架代码的体积 体积越小,最后浏览器加载资源的时间也就越少。
提供友好的提示就意味着需要编写更多的代码,体积也就会越大。这个问题的解决办法就是,区分开发和生产环境。源代码路径
1 2 3 4 5 if (__DEV__ && !res) { warn ( 'Failed to mount app: mount target selector ${container} returnedd null.' ) }
Vue.js源代码使用 roolup.js 对项目进行构建, __DEV__常量是由它来配置的, 当它为false 的时候这段代码就为
1 2 3 4 5 if (false && !res) { warn ( 'Failed to mount app: mount target selector ${container} returnedd null.' ) }
这段代码就永远不会执行, 所以输出就不会包含这段代码 , 这叫做 dead code
如果为true就会包含这段代码, 在构建生产环境
的时候__DEV__会被设置为false, 开发环境
会被设置为true, 用于提供开发环境的提示。
3、框架要做到良好的Tree-Shaking
副作用 = 代码执行后,影响了外部环境的状态,或者产生了可观察的结果。
Tree-shaking 用于消除那些永远不会执行的代码也就是dead code
, 或者是不会产生副作用
的代码。
示例: 没有使用的代码会如何处理?
1 2 3 4 import { foo } from './utils.js' const obj = {};foo (obj);
1 2 3 4 5 6 7 export function foo (obj ) { obj && obj.foo ; } export function bar (obj ) { obj && obj.bar ; }
使用npx rollup input.js -f esm -o bundle.js
打包。
bar没有被调用所以是没有副作用的方法会被去除。
foo方法虽然被调用了但是其中只进行访问了obj没有任何操作也是没有副作用, 所以这里输出的bundle.js为空。
示例: 如果foo不传入参数但是调用了
1 2 3 4 import { foo } from './utils.js' const obj = {};foo ();
1 2 3 4 5 6 7 export function foo (obj ) { console .log (1 ); } export function bar (obj ) { obj && obj.bar ; }
foo内打印了1所以是有副作用的, 所以会被保留
obj对象没有被使用是没有副作用的会被删除。
所以再次打包后bundle.js的结果是
1 2 3 4 5 6 7 function foo (obj ) { console .log (1 ); } foo ();
示例: 如果使用Object.defineProperty会如何处理?
1 2 3 4 5 6 7 8 9 import { foo } from './utils.js' const obj = {};Object .defineProperty (obj, "foo" , { get ( ) { console .log (1 ); } }); foo (obj);
1 2 3 4 5 6 7 export function foo (obj ){ obj && obj.foo ; } export function bar (obj ){ obj && obj.bar ; }
这个时候访问foo方法就有副作用了, 因为访问obj.foo会触发get方法输出1。
所以打包后的结果是
1 2 3 4 5 6 7 8 9 10 11 12 13 function foo (obj ) { obj && obj.foo ; } const obj = {};Object .defineProperty (obj, "foo" , { get ( ) { console .log (1 ); } }); foo (obj);
4、框架应该输出怎样的构建产物 IIFE(Immediately Invoked Function Expression):定义后立即执行
1 2 3 (function ( ) { console .log (1 ); })()
可以使用rollup.js指定输出为life来指定输出这种形式的资源。
1 npx rollup input.js -f iife -o bundle.js
输出的结果是
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 (function ( ) { 'use strict' ; function foo (obj ){ obj && obj.foo ; } const obj = {}; Object .defineProperty (obj, "foo" , { get ( ) { console .log (1 ); } }); foo (obj); })();
vue.global.js是IIFE格式形式的资源, 是根据vue源代码编译的。在引入vue后它会自己立刻调用自己, 然后会挂载全局变量Vue。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <script src="https://unpkg.com/vue@3/dist/vue.global.js" ></script> <div id ="app" > {{ message }}</div > <script > const { createApp, ref } = Vue createApp ({ setup ( ) { const message = ref ('Hello vue!' ) return { message } } }).mount ('#app' ) </script >
也可以通过ESM来引入Vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <div id="app" >{{ message }}</div> <script type ="module" > import { createApp, ref } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js' createApp ({ setup ( ) { const message = ref ('Hello Vue!' ) return { message } } }).mount ('#app' ) </script >
带-browser的资源是给游览器使用的, 而带-bundler的资源是给rollup.js或webpack等打包工具使用的, 它们的区别是比如__DEV__
会使用
!!(process.env.NODE_ENV !== 'production')
来代替
vue源代码
1 2 3 4 5 6 if (isBundlerESMBuild) { Object .assign (replacements, { __DEV__ : `!!(process.env.NODE_ENV !== 'production')` , }) }
这样的好处是可以通过webpck配置自行决定构建资源的目标环境。
5、特殊开关 vue提供了很多的可以开启特性, 对于关闭的特性可以使用Tree-Shaking机制让其不包含在打包后的源代码。
比如__FEATURE_OPTIONS_API__实现类似与__DEV__
vue源代码
1 2 3 __FEATURE_OPTIONS_API__ : isBundlerESMBuild ? `__VUE_OPTIONS_API__` : `true` ,
如果是打包工具的话会被替代为__VUE_OPTIONS_API__
这个选项的作用是用于控制是否启用 Vue 2 的 Options API 支持。
6、错误处理 框架的错误处理机制好坏直接决定了用户应用的健壮性 , 还决定了用户处理错误的心智负担 。
例子: 工具模块的错误处理
1 2 3 4 5 6 export default { foo (fn ){ fn && fn (); } }
1 2 3 4 5 import utils from 'utils.js' utils.foo (() => { console .log (bar); })
在工具模块执行的出错了, 这个时候有两个办法, 一个是用户进行try…catch,会增加用户的负担, 如果有上百个类似的函数就需要逐一添加错误处理程序, 还有就是我们来代替用户统一处理错误。
为utils.js添加错误处理
1 2 3 4 5 6 7 8 9 10 export default { foo (fn ) { try { fn && fn (); } catch (e) { console .log ('你遇到了错误' , e) } } }
这个时候js就不会因为错误而结束执行了, 但是如果有多个方法都需要处理过于麻烦, 所以可以添加一个添加错误处理的方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 export default { foo (fn ) { callWithErrorHandlling (fn); }, bar (fn ) { callWithErrorHandlling (fn); } } function callWithErrorHandlling (fn ) { try { fn && fn (); } catch (e) { console .log ('你遇到了错误' , e) } }
虽然现在有统一的错误处理方法了, 但是用户不可以自己处理错误, 需要可以让用户注册统一处理错误的函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 let HandleError = null ;export default { foo (fn ) { callWithErrorHandlling (fn); }, registerErrorHandler (fn ) { handleError = fn; } } function callWithErrorHandlling (fn ) { try { fn && fn (); } catch (e) { handleError (e); } }
1 2 3 4 5 6 7 8 import utils from './utils.js' utils.registerErrorHandler ((e ) => { console .log ('用户处理错误' , e); }) utils.foo (() => { console .log (bar); })
这个时候错误处理的能力就由用户控制, 用户想忽略就忽略, 想要打印就打印, 这其实就是Vue的错误处理的原理, 也可以在Vue中注册统一的错误处理函数。
7、良好的TypeScript类型支持 TypeScript是由微软开源的编程语言, 简称TS, 它是JavaScript的超集, 能够为JavaScript提供类型支持, 使用TS的好处有很多如代码即文档, 编辑器自动提示, 能够一定程度上避免低级bug, 代码的可维护性更强等, 因此对TS类型支持是否完成也成为评价一个框架的好坏。
例子: 返回值类型丢失
1 2 3 function foo (val : any ){ return val; }
根据ts自动类型推导
, 这个函数接受的参数是val, 并且参数类型为any, 该函数直接将参数作为返回值, 该函数的返回值类型是由参数决定的, 如果参数是number类型, 那么返回值应该也是number类型。
1 2 3 4 5 function foo (val : any ){ return val; } let result = foo ('test' );
直接上result类型为any, 因为any类型不会因为传入的参数而改变, 为了达到理想的状态, 需要使用泛型
。
例子: 使用泛型来自动推导类型
1 2 3 4 5 function foo<T>(val : T): T { return val; } let result = foo ('e' );
这个时候result的类型就是想要的String, 如果传入number就是number。
三、Vue.js的设计思路 1、声明式地描述UI vue.js 3是一个声明式的UI框架, 它使用了声明式来描述内容。
vue的声明式描述 1 2 3 <h1 onclick ="handler" > <span > 标题内容</span > </h1 >
在vue的解决方案是(使用模板来描述):
1 2 3 4 5 <template> <h1 @click="handler"> <span>标题内容</span> </h1> </template>
还可以在Vue使用虚拟DOM来描述组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <script setup> import { h } from 'vue' ;function handler ( ) { console .log ('点击了标题' ); } const TitleComponent = { render ( ) { return h ('h1' , { onClick : handler }, [ h ('span' , '标题内容' ) ]); } }; </script> <template > <TitleComponent /> </template >
vue.js会通过render函数的返回值拿到虚拟DOM, h函数就是用于创建虚拟dom对象的, 然后vue.js就可以把组件的内容渲染出来。
使用对象来描述的好处就是可以动态更改标签, 相对模板来说更加的灵活
。
动态描述标签 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <script setup> import { h, ref } from 'vue' ;function handler ( ) { console .log ('点击了标题' ); } function levelIncrease ( ) { level.value = level.value < 6 ? level.value + 1 : 1 ; } let level = ref (1 );const TitleComponent = { render ( ) { return h (`h${level.value} ` , { onClick : handler }, [ h ('span' , '标题内容' ) ]); } }; </script> <template > <button @click ="levelIncrease" > 点击level+1</button > <TitleComponent /> </template >
这样可以动态的描述标签, 比模板更加灵活, 如果使用模板的话远没有使用js对象灵活, 下面是使用模板实现的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <script setup> import { ref } from 'vue' ;function handler ( ) { console .log ('点击了标题' ); } function levelIncrease ( ) { level.value = level.value < 6 ? level.value + 1 : 1 ; } let level = ref (1 );</script> <template > <button @click ="levelIncrease" > 点击level+1</button > <h1 v-if ="level == 1" @click ="handler" > 标题内容</h1 > <h2 v-else-if ="level == 2" @click ="handler" > 标题内容</h2 > <h3 v-else-if ="level == 3" @click ="handler" > 标题内容</h3 > <h4 v-else-if ="level == 4" @click ="handler" > 标题内容</h4 > <h5 v-else-if ="level == 5" @click ="handler" > 标题内容</h5 > <h6 v-else-if ="level == 6" @click ="handler" > 标题内容</h6 > </template >
2、初识渲染器 上面使用了虚拟dom来构建真实的dom, 其实是利用到了渲染器把js对象变为真实的dom。
例子: 自制渲染器
假如有以下虚拟dom, 这不是vue的虚拟dom
1 2 3 4 5 6 7 const vnode = { tag : 'div' , props : { onClick : () => alert ('hello' ) }, children : 'click me' }
编写一个渲染器用于渲染上面的虚拟dom为真实dom, 第一个参数是虚拟dom对象, 第二个参数是真实dom对象作为挂载点, 会把虚拟dom渲染到该对象下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 function renderer (wnode, container ) { const el = document .createElement (wnode.tag ); for (const key in vnode.props ) { if (/^on/ .test (key)) { el.addEventListener ( key.substr (2 ).toLowerCase (), vnode.props [key] ); } } if (typeof vnode.children == 'string' ) { el.appendChild (document .createTextNode (wnode.children )); } else if (Array .isArray (wnode.children )) { vnode.children .forEach (child => renderer (child, el)); } container.appendChild (el); }
使用这个渲染器把上面的虚拟dom渲染出来
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 <!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 src ="renderer.js" > </script > <script > const vnode = { tag : 'div' , props : { onClick : () => alert ('hello' ) }, children : 'click me' } const app = document .getElementById ('app' ); renderer (vnode, app) </script > </body > </html >
实际上vue的渲染器还会响应式更新, 通过diff找出修改了的地方, 然后更新元素。
3、组件的本质 组件的本质就是一组DOM元素的封装 , 这组DOM元素就是组件要渲染的内容, 可以使用一个函数来代表组件, 返回值就代表组件要渲染的内容。
添加组件的处理 1 2 3 4 5 6 7 8 9 const MyComponent = function ( ) { return { tag : 'div' , props : { onClick : () => alert ('hello' ) }, children : 'click me' } }
使用虚拟DOM标识上面的组件
1 2 3 const vnode = { tag : MyComponent }
但是前面的render函数没有处理tag为组件的情况, 需要为render函数添加处理tag为组件的处理方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 function renderer (vnode, container ) { if (typeof vnode.tag == 'string' ) { mountElement (vnode, container); } else if (typeof vnode.tag == 'function' ) { mountComponent (vnode, container); } } function mountElement (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] ); } } if (typeof vnode.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 (); renderer (subtree, container) }
使用对象来表示组件 渲染组件就是先调用tag里面的组件方法拿到返回值, 再进行渲染, 组件不一定是函数 , vue的渲染组件不是用的方法而是用的对象。
1 2 3 4 5 6 7 8 9 10 11 const MyComponent = { render ( ) { return { tag : 'div' , props : { onClick : () => alert ('hello' ) }, children : 'click me' } } }
需要修改一下渲染函数, 调用组件对象内的render()函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 function renderer (vnode, container ) { if (typeof vnode.tag == 'string' ) { mountElement (vnode, container); } else if (typeof vnode.tag == 'object' ) { mountComponent (vnode, container); } } function mountElement (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] ); } } if (typeof vnode.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 .render (); renderer (subtree, container) }
4、模板的工作原理 无法是虚拟DOM(渲染函数), 还是使用模板都是声明式地描述UI, vue.js同时支持这两种方式描述UI, 上面主要是讲述虚拟DOM, 模板的工作主要是依靠编译器 。
编译器的主要是工作是把模板编译为渲染函数
1 2 3 <div @click="handler" > click me </div>
经过编译器编译后的结果:
1 2 3 render ( ) { return .............. }
vue文件就是一个组件, template标签内的内容就是模板内容
1 2 3 4 5 <template> <div @click ="handler" > click me </div > </template>
编译器会把模板内容编译成渲染函数并且添加到组件对象上。
无论使用模板还是手写渲染函数, 对于一个组件来说, 它要渲染的内容都是通过渲染函数产生的, 然后渲染器再把渲染函数返回的虚拟DOM渲染为真实DOM。
5、Vue.js是各个模块组成的有机整体 组件的实现依赖于渲染器 , 模块的编译依赖于编译器 , 并且编译后生成的代码是根据渲染器和虚拟DOM的设计决定的。因此Vue.js的各个模块之间是互相关联、互相制约的, 共同构成一个有机整体。
编译器和渲染器是如何配合工作, 并且实现性能提升的?
有以下模板:
1 <div id="foo" :class ="cls" ></div>
编译成渲染函数(实际上对象不会这么简单):
1 2 3 4 5 6 7 8 9 render ( ) { return { tag : 'div' , props : { id : 'foo' , class : cls } } }
cls是一个变量, 如果更改了后, 渲染器需要找到它, 并且重新渲染, 这个寻找需要消耗一定的时间成本, vue.js在编译阶段会识别出来可能变化的内容, id不会变化, 但是class
可能会发生变化, 所以会在渲染函数添加一个标记(PatchFlag ), 用来标记这个虚拟DOM对象可能发生变化, 这样只需要去关注class的变化。
四、响应系统的作用与实现 1、响应式数据与副作用函数 副作用函数 是指会产生副作用的函数:
1 2 3 4 function effect ( ) { const ageSpan = document .getElementById ('age' ); ageSpan.innerText = 18 ; }
当effect执行的时候会设置ageSpan的文本内容, 这个时候就产生了副作用, 影响了外部的内容, 副作用很容易产生, 比如修改了全局变量, 打印内容, 甚至读取变量。
在一个副作用函数中读取了某个对象的属性:
1 2 3 4 5 const obj = { age : 18 };function effect ( ) { const ageSpan = document .getElementById ('age' ); ageSpan.innerText = obj.age ; }
上面的函数会设置ageSpan的innerText, 当obj.age的值改变后, 应该ageSpan的值也跟着变化, 也就是重新执行effect, 如果能够做到这点那么obj就是响应式数据。
2、响应式数据的基本实现 如何实现响应式数据?
读取obj.age会触发get()
设置obj.age会发出set()
可以设置一个拦截对象的读取和写入, 当读取obj.age的时候, 把副作用函数effect存储到一个桶
内, 这就是依赖收集 , 然后在set()后重新执行收集的依赖。
可以通过Object.definePropecty 和Proxy 对象来实现, 对象属性的读取和设置操作
使用definePropecty设计 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 <!DOCTYPE html > <html > <head > <meta charset ="utf-8" /> <title > </title > </head > <body > <button onclick ="changeText()" > 修改内容</button > <span > 我今年<span id ="age" > </span > 岁 </span > <script > let bucket = new Set (); const obj = { age : 18 }; let ageValue = obj.age ; Object .defineProperty (obj, 'age' , { set (newValue ) { console .log ('触发设置' ); ageValue = newValue; bucket.forEach (fn => fn ()) }, get ( ) { console .log ('触发读取' ); bucket.add (effect); return ageValue; } }) function effect ( ) { const ageSpan = document .getElementById ('age' ); ageSpan.innerText = obj.age ; } function changeText ( ) { obj.age ++; } effect (); </script > </body > </html >
使用Proxy设计 使用Proxy对象来更方便的实现, 可以不使用中间变量来存储数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 <!DOCTYPE html > <html > <head > <meta charset ="utf-8" /> <title > </title > </head > <body > <button onclick ="changeText()" > 修改内容</button > <span > 我今年<span id ="age" > </span > 岁 </span > <script > let bucket = new Set (); const data = { age : 18 }; const obj = new Proxy (data, { get (target, key ){ bucket.add (effect); return target[key]; }, set (target, key, newValue ){ target[key] = newValue; bucket.forEach (fn => fn ()) return true ; } }) function effect ( ) { const ageSpan = document .getElementById ('age' ); ageSpan.innerText = obj.age ; } function changeText ( ) { obj.age ++; } effect (); </script > </body > </html >
上面的代码可以实现简单的响应式系统, 但是不够灵活 bucket.add(effect);
是硬编码, 应该更加灵活的添加副作用方法, 并且如果对象有多个属性, 每个属性都应该有一个自己的桶。
3、设计一个完善的响应系统 响应式系统的优化 响应式系统的工作流程:
当读取操作 发生时, 将副作用函数收集到桶
中
当设置操作 发生时, 从桶
中取出副作用函数并执行
上面添加到桶内的函数是固定的, 硬编码了副作用函数effect, 如果副函数不叫effect就无法使用了。
可以提供一个注册副作用函数的机制
1 2 3 4 5 6 7 8 9 let activeEffect;function effect (fn ){ activeEffect = fn; fn (); }
通过调用effect可以注册副作用函数, 然后在Proxy对象的get内部只需要获取activeEffect的值就行了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const obj = new Proxy (data, { get (target, key ){ if (activeEffect) { bucket.add (activeEffect); } return target[key]; }, set (target, key, newValue ){ target[key] = newValue; bucket.forEach (fn => fn ()) return true ; } })
使用effect收集依赖后, 由于所有属性都是使用的一个bucket
, 所有属性的set都会导致依赖的重新调用, 应该把副作用函数和操作的字段建立联系。
创建一个新的桶WeakMap代替Set作为桶的数据结构:
1 const bucket = new WeakMap ();
修改get/set拦截器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 const obj = new Proxy (data, { get (target, key ) { if (!activeEffect) return target[key]; let depsMap = bucket.get (target); if (!depsMap) { bucket.set (target, (depsMap = new Map ())); } let deps = depsMap.get (key); if (!deps) { depsMap.set (key, (deps = new Set ())); } deps.add (activeEffect); return target[key]; }, set (target, key, newValue ) { target[key] = newValue; const depsMap = bucket.get (target); if (!depsMap) return ; const effects = depsMap.get (key); effects && effects.forEach (fn => fn ()) } })
上面的bucket
的结构是
WeakMap: target --> Map
Map: Key --> Set
Set: effect-1,effect-2,effect-3.....
为什么使用weakmap而不是map 1 2 3 4 5 6 7 8 const map = new Map ();const weakmap = new WeakMap ();(function ( ) { const foo = {foo : 1 }; const bar = {bar : 2 }; map.set (foo, 1 ); weakmap.set (bar, 2 ); })()
WeakMap
的设计初衷是让键(key)可以被“弱引用”,这样当键对象不再被引用时,它可以被垃圾回收器自动清理。
foo
对象 :
在 Map
中,foo
是作为强引用存在的。即使 IIFE 执行完毕后,foo
仍然会存在,因为 Map
保留了对它的引用。
bar
对象 :
在 WeakMap
中,bar
是作为弱引用存在的。由于 WeakMap
不阻止对象的垃圾回收,IIFE 执行完后,bar
对象不再被其他引用所指向 ,所以它会被垃圾回收器清理掉。
上面的场景中用户代码如果代理的对象已经不存在了, 这个key已经没有存在的意义了, 所以需要使用WeakMap。
封装函数track和trigger 把get和set的逻辑封装成函数track和trigger
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 const obj = new Proxy (data, { get (target, key ) { track (target, key); return target[key]; }, set (target, key, newValue ) { target[key] = newValue; trigger (target, key); } }) function track (target, key ) { if (!activeEffect) return target[key]; let depsMap = bucket.get (target); if (!depsMap) { bucket.set (target, (depsMap = new Map ())); } let deps = depsMap.get (key); if (!deps) { depsMap.set (key, (deps = new Set ())); } deps.add (activeEffect); } function trigger (target, key ) { const depsMap = bucket.get (target); if (!depsMap) return ; const effects = depsMap.get (key); effects && effects.forEach (fn => fn ()) }
把trigger和track封装成函数后, 增加了灵活性
和复用性
。
分支切换与cleanup 分支切换导致的副作用函数遗留问题
1 2 3 4 const data = { ok : true , text : 'hello' };effect (() => { document .getElementById ('text' ).innerHTML = obj.ok == true ? obj.text : 'no' ; })
effect内部传入了一个函数内部是一个三元表达式, 当obj.ok改变后, 这个副作用函数重新执行了, 执行的分支改变了, 这就是分支切换。
1 2 3 4 5 6 7 8 9 10 dqstIFLX4614 └── text : effects └── effect.... └── ok : effects └── effect.... 遗留的副作用函数会导致不必要的更新, 比如上面的obj.ok 为false 的时候, obj.text 更新后, effect副作用函数会重新执行 `` `js document.getElementById('text').innerHTML = obj.ok == true ? obj.text : 'no';
但是obj.ok为false时, 这个表达式的结果永远为no, obj.text导致了不必要的更新。
解决副作用函数遗留问题
要解决这个问题, 需要每次副作用函数执行时, 可以把副作用函数从关联的依赖集合中移除, 重新收集依赖 , 要实现这个就需要知道哪些依赖集合中包含它, 需要重新设计effect函数。
1 2 3 4 5 6 7 8 9 10 11 12 function effect (fn ) { const effectFn = ( ) => { activeEffect = effectFn; fn (); } effectFn.deps = []; effectFn (); }
添加一个effectFn作为执行的副作用函数, 并且添加原型deps用于存储依赖集合。
然后就需要修改track函数, 添加依赖到列表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 function track (target, key ) { if (!activeEffect) return target[key]; let depsMap = bucket.get (target); if (!depsMap) { bucket.set (target, (depsMap = new Map ())); } let deps = depsMap.get (key); if (!deps) { depsMap.set (key, (deps = new Set ())); } deps.add (activeEffect); activeEffect.deps .push (deps); } function effect (fn ) { const effectFn = ( ) => { cleanup (effectFn); activeEffect = effectFn; fn (); } effectFn.deps = []; effectFn (); } function cleanup (effectFn ) { for (deps of effectFn.deps ) { deps.delete (effectFn); } effectFn.deps .length = 0 ; }
测试后发现上面代码出现了死循环, 问题出现在trigger函数
1 2 3 4 5 6 7 function trigger (target, key ) { const depsMap = bucket.get (target); if (!depsMap) return ; const effects = depsMap.get (key); effects && effects.forEach (fn => fn ()) }
effects.forEach(fn => fn())
执行方法的时候, 调用cleanup, 然后会移除effects中的相关副作用函数, 然后又会执行副作用函数, 添加到effects。
用下面的代码来说明:
1 2 3 4 5 6 7 const set = new Set ([1 ]);set.forEach ((item ) => { set.delete (1 ); set.add (1 ); console .log ('遍历中' ); });
如果一个值已经被访问过了, 但是该值被删除并重新添加到集合, 如果此时forEach遍历没有结束, 那么该值会重新被访问。
解决set无限循环
使用另一个set集合并遍历它就能解决这个问题, 就不会导致无限循环。
1 2 3 4 5 6 7 8 const set = new Set ([1 ]);const newSet = new Set (set);newSet.forEach (() => { set.delete (1 ); set.add (1 ); console .log ('遍历中' ); });
解决之前trigger的无限循环:
1 2 3 4 5 6 7 function trigger (target, key ) { const depsMap = bucket.get (target); if (!depsMap) return ; const effects = depsMap.get (key); const effectToRun = new Set (effects); effectToRun.forEach (fn => fn ()); }
构建effectToRun集合并遍历它, 替代直接遍历effects集合, 从而避免了无限执行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function effect (fn ) { const effectFn = ( ) => { cleanup (effectFn); activeEffect = effectFn; fn (); console .log (effectFn.deps ); } effectFn.deps = []; effectFn (); }
查看后发现已经成功的解决了副作用函数的遗留问题。
嵌套的effect与effect栈 effect是可以发生嵌套的: