flow前置在Vue源码里,尤大采用了Flow作为静态类型检查,Flow是facebook出品的静态类型检查工具。
为什么要用Flow?众所周知,JavaScript是弱类型的语言。
所谓弱类型指的是定义变量时,不需要什么类型,在程序运行过程中会自动判断类型,如果一个语言可以隐式转换它的所有类型,那么它的变量、表达式等在参与运算时,即使类型不正确,也能通过隐式转换来得到正确地类型,这对使用者而言,就好像所有类型都能进行所有运算一样,所以Javascript被称作弱类型。
可能在初期的时候,这个特点有时候用着很爽,但当你在一个较大的项目中的时候,就会发现这个特性不是一般的麻烦,同事往往不太清楚你所写的函数到底要哪种类型的参数,而且代码重构的时候也很麻烦。
于是基于这个需求有了Typescript和Flow的产生,但是TypeScript学习成本较大,一般来说不会为了一些便利去学习一门语言,所以Facebook在四年前开源了Flow。
Vue为什么要用Flow而不用Typescript开发框架呢?尤雨溪知乎的回答是这样的。
具体怎么用,可以到这学习查看中文文档。本文主要讲Vue源码技巧,不会过多解释Flow。
项目架构Vue.js 是一个典型的 MVVM框架,核心思想是数据驱动和组件化。DOM是数据的一种自然映射,在Vue中只需要修改数据即可达到DOM更新的目的。组件化是将页面上每一个独立的功能块或者交互模块视作一个组件,把页面看做是容器,从而实现搭积木式的开发方式。把源码download到本地我们看下目录结构
目录结构
Vue源码目录分工明确。整个目录大致分为
- benchmarks:处理大量数据时测试Demo
- dist:各环境所需的版本包
- examples:用Vue实现的一些实用Demo
- flow: 数据类型检测配置
- packages: 特定环境运行需要单独安装的插件
- src: 整个源码的核心。
- script: npm脚本配置文件
- test: 测试用例
- types: 新版typescript配置
核心代码都在src目录下,其中包含实例化、数据响应式处理、模板编译、事件中心、全局配置等等都在这个目录下。
从入口开始从编译器,找到根目录下的package.json文件,可以看到在script里有一个dev,这个文件生成了rollup打包器的配置,
rollup -w -c scripts/config.js --environment TARGET:web-full-dev复制代码rollup表示它使用了rollup打包器,-w表示watch监听文件变化,c表示config使用配置文件来打包,如果后面没有指定文件就默认指定rollup.config.js,再后面表示指定scripts/config.js配置rollup,--environment表示设置环境变量,后面携带参数TARGET:web-full-dev表示环境变量名和值,我们再到scripts/config.js,可以看到环境变量参数已经带过来并且触发了genConfig()函数
那genConfig()做了什么
其他的隐藏暂时不看,首先const opts = builds[name]在builds变量查找到配置。定义了入口文件和输出配置,如果定义了运行环境,就储存到该字段。然后在这个文件里找到web-full-dev对应的配置是这样的:它主要申明了入口entry和模块定义format、输出dest、环境名称env,rollup编译alias,框架信息banner,入口是web/entry-runtime-with-compiler.js,但是在当前目录并没有web文件夹,那怎么找呢?在上面我们可以看到有一个resolve()路径代理函数
利用split切割传入的文件名匹配引入的alias配置、最终定位到src/platforms/web/entry-runtime-with-compiler.js,找到Vue在这储存了$mount的方法并且新申明了一个$mount的方法,利用储存的mount方法在底部再次进行挂载处理并将结果返回。为什么要重新申明,查阅资料后知道原来runtime-only版本并没有后申明的$mount这部分的处理,这样的做就可以在保持原有函数的基础上进行复用,这一点值得我们去学习。
不轻易修改原有逻辑,但是可以将原有的函数储存起来,再重新声明。
整体流程先看大概的整体流程
- 首次渲染,执行compileToFunctions()将模板template解析成renderFn(render函数),如果renderFn已存在就跳过此部
- 将renderFn通过vm._render()编译成Vnode,在读取其中变量的同时,Watcher通过Object.defindProperty()的get方法收集依赖到dep,开始监听
- 执行updataComponent(),首先到vdom的patch()方法会将vnode渲染成真实DOM
- 将DOM挂载到节点上,等待data发生改变
- data属性发生变化,首先查看收集的依赖中是否存在该data值的引用,不存在就不管,存在则触发Object.defindProperty()的set方法修改值并且执行_updata 进行 patch() 和updataComponent()进行组件更新
大致分为
esm 完整构建 :包含模板编译器,渲染过程 HTML字符串 → render函数 → VNode → 真实DOM节点
runtime-only 运行时构建 :不包含模板编译器,渲染过程 render函数 → VNode → 真实DOM节点
runtime-only版本是没有template=>render这一步的,不带模板编译器。
解释一下各类词汇- template 模板 :Vue的模板基于纯HTML,基于Vue的模板语法,还是可以按照以前HTML式写结构。
- AST 抽象语法树: Abstract Syntax Tree 的简称,主要做三步
- parse:Vue使用HTML的Parser将HTML模板解析为AST
- optimizer:对AST进行一些优化static静态节点的标记处理,提取最大的静态树,当_update更新界面时,会有一个patch的过程,diff算法会直接跳过静态节点,从而减少了patch的过程,优化了patch的性能
- generateCode:根据 AST 生成 render 函数
- renderFn 渲染函数 :渲染函数是用来生成Virtual DOM(vdom)的。Vue推荐使用模板来构建我们的应用界面,在底层实现中Vue会将模板编译成renderFn函数,当然我们也可以不写模板,直接写渲染函数,以获得更好的控制
- Virtual DOM (vdom,也称为VNode):虚拟DOM树,Vue的Virtual DOM Patching算法是基于 Snabbdom库 的实现,并在些基础上作了很多的调整和改进。只能通过RenderFn执行vm._render()生成,patch的目标都是Vnode,并且每个Vnode在全局都是唯一的
- patch:在上面vdom已经说到这个,但还是要说一句,patch是整个virtaul-dom当中最为核心的方法,主要功能是对旧vnode和新vnode进行diff的过程,最后生成新的DOM节点通过updataComponent()方法重新渲染,vue对此做了相当多的性能优化
- Watcher (观察者):每个Vue组件都有一个对应的 Watcher ,这个 Watcher 将会在组件 render 的时候收集组件所依赖的数据,并在依赖有更新的时候,触发组件vm._updata调用patch()进行diff,重新渲染DOM。
不扯废话,开撸
挂载
新挂载$mount的这个方法。
Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean): Component { el = el && query(el) .....复制代码key?:value (key: value|void);
el?:string|Element是flow的语法,表示传入的el字符串可以是string和Element以及void类型——undefined类型,hydrating?: boolean同样,必须是布尔类型和undefined。
key:?value (key: value|void|null);
表示该key必须为value或者undefined以及null类型。
function():value (:value):Component表示函数返回值必须为Component类型。
function(key:value1|value2) (key:value1|value2)表示key必须为value1或者是value2类型。
编译RenderFnel = el && query(el)对传入的el元素节点做了确认,如果传入的节点容器没有找到的便警告并且return一个createElement('div')新的div。
//判断传入的标签如果是body或者是页面根节点//就警告禁止挂载在页面根节点上,因为挂载会替换该节点。最后返回该节点 if (el === document.body || el === document.documentElement) { process.env.NODE_ENV !== 'production' && warn( `Do not mount Vue to <html> or <body> - mount to normal elements instead.` ) return this } const options = this.$options; if (!options.render) { //如果接受的值已经有写好的RenderFn,则不用进行任何操作,如果render不存在,就进入此逻辑将模板编译成renderFn let template = options.template if (template) { ... //有template就使用idToTemplate()解析,最终返回该节点的innerHTML } if (typeof template === 'string') { if (template.charAt(0) === '#') {//如果模板取到的第一个字符是# template = idToTemplate(template) if (process.env.NODE_ENV !== 'production' && !template) {//开发环境并且解析模板失败的报错:警告模板为空或者未找到 warn( `Template element not found or is empty: ${options.template}`, this ) } } }else if (template.nodeType) { //如果有节点类型,判定是普通节点,也返回innerHTML template = template.innerHTML } else { //没有template就警告该模板无效 if (process.env.NODE_ENV !== 'production') { warn('invalid template option:' + template, this) } return this } } else if (el) { //如果是节点的话,获取html模板片段,getOuterHTML()对传入的el元素做了兼容处理,最终目的是拿到节点的outerHTML //getOuterHTML()可以传入DOM节点,CSS选择器,HTML片段 template = getOuterHTML(el) } if (template) { //编译HTML生成renderFn,赋给options,vm.$options.render此时发生变化 if (process.env.NODE_ENV !== 'production' && config.performance && mark) { //开始标记 mark('compile') } /* compileToFunctions()主要是将getOuterHTML获取的模板编译成RenderFn函数,该函数的具体请往后翻看 * 具体步骤之后再说,编译大致主要分成三步 * 1.parse:将 html 模板解析成抽象语法树(AST)。 * 2.optimizer:对 AST 做优化处理。 * 3.generateCode:根据 AST 生成 render 函数。 */ const { render, staticRenderFns } = compileToFunctions(template, { shouldDecodeNewlines, shouldDecodeNewlinesForHref, delimiters: options.delimiters, comments: options.comments }, this) options.render = render; //最后将解析的renderFn 赋值给当前实例 options.staticRenderFns = staticRenderFns //编译的配置 if (process.env.NODE_ENV !== 'production' && config.performance && mark) { //结束标记 mark('compile end') //根据mark()编译过程计算耗时差,用于到控制台performance查看阶段渲染性能 measure(`vue ${this._name} compile`, 'compile', 'compile end') } } } //最后返回之前储存的mount()方法进行挂载,如果此前renderFn存在就直接进行此步骤 return mount.call(this, el, hydrating)}复制代码这里最重要的就是compileToFunctions()将template编译成RenderFn,该方法请通过目录跳转查看。
本段代码对template的多种写法做兼容处理,最终取到renderFn,过程中顺带进行性能埋点等辅助功能。最后return mount.call(...)这个在
import Vue from './runtime/index'复制代码编译的过程比较复杂,之后再说。到这发现Vue的原型方法并不是在这建立的,我们需要到上一级src/platforms/runtime/index.js,
// 配置了一些全局的方法Vue.config.mustUseProp = mustUseProp Vue.config.isReservedTag = isReservedTag Vue.config.isReservedAttr = isReservedAttrVue.config.getTagNamespace = getTagNamespaceVue.config.isUnknownElement = isUnknownElement// 安装平台的指令和组件extend(Vue.options.directives, platformDirectives)extend(Vue.options.components, platformComponents)// 如果在浏览器里,证明不是服务端渲染,添加__patch__方法Vue.prototype.__patch__ = inBrowser ? patch : noop// 挂载$mount方法。Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean): Component { //必须在浏览器环境才返回该节点,runtime-only版本会直接运行到这 el = el && inBrowser ? query(el) : undefined return mountComponent(this, el, hydrating)}复制代码hydrating这个传参可以全局性的理解为,服务端渲染,默认false。最后进行mountComponent(this, el, hydrating)其实就是对组件进行一个update和watcher的过程。具体看下mountComponent做了什么。找到src/core/instance/lifecycle.js,这个文件负责为实例添加生命周期类函数.
export function mountComponent ( vm: Component, el: ?Element, hydrating?: boolean ): Component { vm.$el = el //首先将vm.$el将传入的el做缓存,$el现在为真实的node if (!vm.$options.render) { //因为最后只认renderFn,如果没有的话,就创建一个空节点Vnode vm.$options.render = createEmptyVNode if (process.env.NODE_ENV !== 'production') {//开发环境下 if ((vm.$options.template && vm.$options.template.charAt(0) !== '#') || vm.$options.el || el) { /* (如果定义了template但是template首位不是'#')或者(没有传入element),就会警告当前使用的是runtime-only版本, 默认不带编译功能,如果需要编译的话,则需要更换构建版本,下面类似 */ } else { warn(//挂载组件失败:template或者renderFn未定义 'Failed to mount component: template or render function not defined.', vm ) } } } // 在挂载之前为当前实例初始化beforMount生命周期 callHook(vm, 'beforeMount'); // 声明了一个 updateComponent 方法,这个是将要被 Watcher实例调用的更新组件的方法。 // 根据性能的对比配置不同的更新方法, // performance+mark可以用于分析Vue组件在不同阶段中花费的时间,进而知道哪里可以优化。 let updateComponent if (process.env.NODE_ENV !== 'production' && config.performance && mark) { updateComponent = () => { //获取组件标记 const name = vm._name const id = vm._uid const startTag = `vue-perf-start:${id}` const endTag = `vue-perf-end:${id}` mark(startTag);//标记开始节点 const vnode = vm._render();//生成一个Vnode mark(endTag);//标记结束节点 //做performance命名'vue ${name} render',这样就可以在proformance中查看应用程序的运行状况、渲染性能,最后删除标记和度量 measure(`vue ${name} render`, startTag, endTag); mark(startTag); vm._update(vnode, hydrating); mark(endTag) measure(`vue ${name} patch`, startTag, endTag); } } else { updateComponent = () => { // 定义一个渲染watcher函数 // vm._render()里会调用render函数,并返回一个VNode,在生成VNode的过程中,会动态计算getter,同时推入到dep里面进行数据监听,每次数据更新后都出触发当前实例的_updata进行组件更新 // _update()方法会将新vnode和旧vnode进行diff比较,最后完成dom的更新工作,该方法请往下移步 vm._update(vm._render(), hydrating) } } /* 新建一个_watcher对象,将监听目标推入dep,vm实例上挂载的_watcher主要是为了更新DOM调用当前vm的_watcher 的 update 方法。用来强制更新。为什么叫强制更新呢? * vue里面有判断,如果newValue == oldValue, 那么就不触发watcher更新视图了 * vm:当前实例 * updateComponent:用来将vnode更新到之前的dom上 * noop:无效函数,可以理解为空函数 * {before(){...}}:配置,如果该实例已经挂载了,就配置beforeUpdate生命周期钩子函数 * true:主要是用来判断是哪个watcher的。因为computed计算属性和如果你要在options里面配置watch了同样也是使用了 new Watcher ,加上这个用以区别这三者 */ new Watcher(vm, updateComponent, noop, { before () { if (vm._isMounted) { callHook(vm, 'beforeUpdate') } } }, true ) hydrating = false //关闭服务端渲染,服务端渲染只有created()和beforeCreate() if (vm.$vnode == null) { vm._isMounted = true callHook(vm, 'mounted') } return vm}复制代码这个函数的具体作用就是挂载节点,并对data做响应式处理。至于为什么会有个判断语句来根据条件声明 updateComponent方法,其实从 performance 可以看出,其中一个方法是用来测试render 和 update 性能的。便于在Chrome=>performance中查看渲染性能
process.env.NODE_ENV !== 'production' && config.performance && mark复制代码首先判断当前的环境和是否配置支持performance,然后调用mark和measure方法,其中mark封装了一个方法,具体的API可以参考MDN performance,给当前元素做一个标记,然后返回一个具体的时间点,主要功能是性能埋点
if (process.env.NODE_ENV !== 'production') { //判断当前浏览器runtime是否支持performace const perf = inBrowser && window.performance if ( perf && perf.mark && perf.measure && perf.clearMarks && perf.clearMeasures ) { mark = tag => perf.mark(tag);//标记该节点 measure = (name, startTag, endTag) => { perf.measure(name, startTag, endTag) //作性能埋点后,删除所有的标记和度量 perf.clearMarks(startTag) perf.clearMarks(endTag) perf.clearMeasures(name) } }}复制代码至于刚才的vm._update()在上面lifecyle.js已经定义了
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { //首先接收vnode const vm: Component = this const prevEl = vm.$el;//真实的dom节点 const prevVnode = vm._vnode;//之前旧的vnode const prevActiveInstance = activeInstance;// null activeInstance = vm;//获取当前的实例 vm._vnode = vnode;//当前新的vnode if (!prevVnode) { // 如果需要diff的旧vnode不存在,就无法进行__patch__ // 因此需要用新的vnode创建一个真实的dom节点 vm.$el = vm.__patch__( vm.$el, //真实的dom节点 vnode, //传入的vnode hydrating, //是否服务端渲染 false /* removeOnly是一个只用于 <transition-group> 的特殊标签,确保移除元素过程中保持一个正确的相对位置。 */) } else { // 如果需要diff的prevVnode存在,那么首先对prevVnode和vnode进行diff // 并将需要的更新的dom操作已patch的形式打到prevVnode上,并完成真实dom的更新工作 vm.$el = vm.__patch__(prevVnode, vnode) } activeInstance = prevActiveInstance;// // 如果存在真实的dom节点 if (prevEl) { //就将之前的__vue__清空,再挂载新的 prevEl.__vue__ = null } // 将更新后的vm挂载到的vm__vue__上缓存 if (vm.$el) { vm.$el.__vue__ = vm } // 如果当前实例的$vnode与父组件的_vnode相同,也要更新其$el if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) { vm.$parent.$el = vm.$el } }复制代码怎么进行patch?__patch__是整个整个virtaul-dom当中最为核心的方法了,主要功能是对prevVnode(旧vnode)和新vnode进行diff的过程,经过patch比对,最后生成新的真实dom节点更新改变部分的视图。在/packages/factory.js里,定义了patch(),代码过多,只摘取重要部分,目前清楚流程即可,vue2.0+是参考snabbdom建立的patch虚拟dom算法
return function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) { //用到的参数,oldVnode:旧的vnode、vnode:新的vnode、hydrating:服务端渲染、removeOnly:避免误操作 //当新的vnode不存在,并且旧的vnode存在时,直接返回旧的vnode,不做patch if (isUndef(vnode)) { if (isDef(oldVnode)) { invokeDestroyHook(oldVnode); } return } var insertedVnodeQueue = []; //如果旧的vnode不存在 if (isUndef(oldVnode)) { //就创建一个新的节点 createElm(vnode, insertedVnodeQueue, parentElm, refElm); } else { //获取旧vnode的节点类型 var isRealElement = isDef(oldVnode.nodeType); // 如果不是真实的dom节点并且属性相同 if (!isRealElement && sameVnode(oldVnode, vnode)) { // 对oldVnode和vnode进行diff,并对oldVnode打patch patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly); } } } //最后返回新vnode的节点内容 return vnode.elm }复制代码这是一个基本的patch,它的目标转到/src/core/vdom/patch.js的patchVnode(),并且通过sameVnode()可以预先比对旧vnode和新vnode两者的基础属性,这个方法决定了接下来是否需要对oldVnode和vnode进行diff
function sameVnode (a, b) { return ( a.key === b.key && a.tag === b.tag && a.isComment === b.isComment && isDef(a.data) === isDef(b.data) && sameInputType(a, b) )}复制代码只有当基本属性相同的情况下才认为这个2个vnode只是局部发生了更新,然后才会对这2个vnode进行diff,如果2个vnode的基本属性存在不一致的情况,那么就会直接跳过diff的过程,进而依据vnode新建一个真实的dom,同时删除老的节点。首次渲染的时候,oldVnode并不存在,所以直接进行domcreateElm(vnode, insertedVnodeQueue, parentElm, refElm);创建一个新的节点,相反,存在oldVnode,当oldVnode和vnode都存在且sameVnode(oldVnode, vnode)2个节点的基本属性相同,那么就进入了2个节点的diff过程。
在/src/core/vdom/patch.js里定义里patchVnode函数
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) { /* * 比较新旧vnode节点,根据不同的状态对dom做合理的更新操作(添加,移动,删除)整个过程还会依次调用prepatch,update,postpatch等钩子函数,在编译阶段生成的一些静态子树 * 在这个过程中由于不会改变而直接跳过比对,动态子树在比较过程中比较核心的部分就是当新旧vnode同时存在children,通过updateChildren方法对子节点做更新, * @param oldVnode 旧vnode * @param vnode 新vnode * @param insertedVnodeQueue 空数组,用于生命周期 inserted 阶段,记录下所有新插入的节点以备调用 * @param removeOnly 是一个只用于 <transition-group> 的特殊标签,确保移除元素过程中保持一个正确的相对位置。 */ if (oldVnode === vnode) { return } const elm = vnode.elm = oldVnode.elm // 异步占位 if (isTrue(oldVnode.isAsyncPlaceholder)) { if (isDef(vnode.asyncFactory.resolved)) { hydrate(oldVnode.elm, vnode, insertedVnodeQueue) } else { vnode.isAsyncPlaceholder = true } return } //如果新vnode和旧vnode都是静态节点,key相同,或者新vnode是一次性渲染或者克隆节点,那么直接替换该组件实例并返回 if (isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic) && vnode.key === oldVnode.key && (isTrue(vnode.isCloned) || isTrue(vnode.isOnce)) ) { vnode.componentInstance = oldVnode.componentInstance return } // 可以往下翻去看vnode的例子,data是节点属性,包含class style attr和指令等 let i const data = vnode.data // 如果组件实例存在属性并且存在prepatch钩子函数就更新attrs/style/class/events/directives/refs等属性 if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) { i(oldVnode, vnode) } const oldCh = oldVnode.children const ch = vnode.children //如果新的vnode带有节点属性,isPatchable返回是否含有组件实例的tag标签,两者满足 if (isDef(data) && isPatchable(vnode)) { // cbs保存了hooks钩子函数: 'create', 'activate', 'update', 'remove', 'destroy' for (i = 0; i < cbs.update.length; ++i) cbs.update(oldVnode, vnode) // 取出cbs保存的update钩子函数,依次调用,更新attrs/style/class/events/directives/refs等属性 if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode) } //如果vnode没有文本节点 if (isUndef(vnode.text)) { //如果旧vnode和新vnode的子节点都存在 if (isDef(oldCh) && isDef(ch)) { // 如果子节点不同,updateChildren就对子节点进行diff if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) //如果只存在新vnode } else if (isDef(ch)) { // 先将旧节点的文本清空 if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '') // 然后将vnode的children放进去 addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) // 如果只存在旧vnode } else if (isDef(oldCh)) { // 就删除elm下的oldchildren removeVnodes(elm, oldCh, 0, oldCh.length - 1) // 如果只有旧vnode的文本内容 } else if (isDef(oldVnode.text)) { // 直接清空内容 nodeOps.setTextContent(elm, '') } // 如果是两者文本内容不同 } else if (oldVnode.text !== vnode.text) { // 直接更新vnode的文本内容 nodeOps.setTextContent(elm, vnode.text) } // 更新完毕后,执行 data.hook.postpatch 钩子,表明 patch 完毕 if (isDef(data)) { if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode) } }复制代码
通过比对新旧vnode节点属性、子元素、节点类型和内容等多种方式进行patch,过程中使用hooks更新节点属性。理一下逻辑源码中添加了一些注释便于理解,来理一下逻辑。
- 如果两个vnode相等,不需要 patch。
- 如果是异步占位,执行 hydrate 方法或者定义 isAsyncPlaceholder 为 true,然后退出。
- 如果两个vnode都为静态,不用更新,所以将以前的 componentInstance 实例传给当前 vnode。 退出patch
- 执行 prepatch 钩子。
- 依次遍历调用 update 回调,执行 update钩子。更新attrs/style/class/events/directives/refs等属性。
- 如果两个 vnode 都有 children,且 vnode 没有 text 文本内容、两个 vnode 不相等,执行 updateChildren 方法。这是虚拟 DOM 的关键。
- 如果新 vnode 有 children,而老的没有,清空文本,并添加 vnode 节点。
- 如果老 vnode 有 children,而新的没有,清空文本,并移除 vnode 节点。
- 如果两个 vnode 都没有 children,老 vnode 有 text ,新 vnode 没有 text ,则清空 DOM 文本内容。
- 如果老 vnode 和新 vnode 的 text 不同,更新 DOM 元素文本内容。
- 调用 postpatch 钩子告知patch完毕。
updateChildren这个有点绕
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { /* * @ parentElm 父元素 * @ oldCh 旧子节点 * @ newCh 新子节点 * @ insertedVnodeQueue 记录下所有新插入的节点以备调用 * @ removeOnly 是仅由<transition-group>使用的特殊标志,在离开过渡期间,确保删除的元素保持正确的相对位置 */ let oldStartIdx = 0 //oldStartIdx => 旧头索引 let newStartIdx = 0 //newStartIdx => 新头索引 let oldEndIdx = oldCh.length - 1 //oldEndIdx => 旧尾索引 let oldStartVnode = oldCh[0] // 旧首索引节点,第一个 let oldEndVnode = oldCh[oldEndIdx] // 旧尾索引节点,最后一个 let newEndIdx = newCh.length - 1 //newEndIdx => 新尾索引 let newStartVnode = newCh[0] // 新首索引节点,第一个 let newEndVnode = newCh[newEndIdx] // 新首索引节点,最后一个 // 可以理解为 // 1. 旧子节点数组的 startIndex, endIndex, startNode, endNode // 2. 新子节点数组的 startIndex, endIndex, startNode, endNode let oldKeyToIdx, idxInOld, vnodeToMove, refElm //可以进行移动 const canMove = !removeOnly if (process.env.NODE_ENV !== 'production') { //首先会检测新子节点有没有重复的key checkDuplicateKeys(newCh) } while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] //如果旧首索引节点和新首索引节点相同 } else if (sameVnode(oldStartVnode, newStartVnode)) { //对旧头索引节点和新头索引节点进行diff更新, 从而达到复用节点效果 patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue) //旧头索引向后 oldStartVnode = oldCh[++oldStartIdx] //新头索引向后 newStartVnode = newCh[++newStartIdx] //如果旧尾索引节点和新尾索引节点相似,可以复用 } else if (sameVnode(oldEndVnode, newEndVnode)) { //旧尾索引节点和新尾索引节点进行更新 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue) //旧尾索引向前 oldEndVnode = oldCh[--oldEndIdx] //新尾索引向前 newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right /* 有一种情况,如果 * 旧【5,1,2,3,4】 * 新【1,2,3,4,5】,那岂不是要全删除替换一遍 5->1,1->2...? * 即便有key,也会出现[5,1,2,3,4]=>[1,5,2,3,4]=>[1,2,5,3,4]...这样太耗费性能了 * 其实我们只需要将5插入到最后一次操作即可 */ // 对旧首索引和新尾索引进行patch patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue) // 旧vnode开始插入到真实DOM中,旧首向右移,新尾向左移 canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left // 同上中可能,旧尾索引和新首也存在相似可能 // 对旧首索引和新尾索引进行patch patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) // 旧vnode开始插入到真实DOM中,新首向左移,旧尾向右移 canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { //如果上面的判断都不通过,我们就需要key-index表来达到最大程度复用了 //如果不存在旧节点的key-index表,则创建 if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) //找到新节点在旧节点组中对应节点的位置 idxInOld = isDef(newStartVnode.key)? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) //如果新节点在旧节点中不存在,就创建一个新元素,我们将它插入到旧首索引节点前(createElm第4个参数) if (isUndef(idxInOld)) { // New element createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else { // 如果旧节点有这个新节点 vnodeToMove = oldCh[idxInOld] // 将新节点和新首索引进行比对,如果类型相同就进行patch if (sameVnode(vnodeToMove, newStartVnode)) { patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue) // 然后将旧节点组中对应节点设置为undefined,代表已经遍历过了,不在遍历,否则可能存在重复插入的问题 oldCh[idxInOld] = undefined // 如果不存在group群体偏移,就将其插入到旧首节点前 canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) } else { // 类型不同就创建节点,并将其插入到旧首索引前(createElm第4个参数) // same key but different element. treat as new element createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } } //将新首往后移一位 newStartVnode = newCh[++newStartIdx] } } //当旧首索引大于旧尾索引时,代表旧节点组已经遍历完,将剩余的新Vnode添加到最后一个新节点的位置后 if (oldStartIdx > oldEndIdx) { refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) } //如果新节点组先遍历完,那么代表旧节点组中剩余节点都不需要,所以直接删除 else if (newStartIdx > newEndIdx) { removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) } }复制代码Vnode在/src/core/vdom/vnode.js中有定义Vnode属性
export default class VNode { constructor ( tag?: string, data?: VNodeData, children?: ?Array<VNode>, text?: string, elm?: Node, context?: Component, componentOptions?: VNodeComponentOptions, asyncFactory?: Function ) { this.tag = tag //标签属性 this.data = data //渲染成真实DOM后,节点上到class attr style 事件等... this.children = children //子节点,也上vnode this.text = text // 文本 this.elm = elm //对应着真实的dom节点 this.ns = undefined //当前节点的namespace(命名空间) this.context = context //编译的作用域 this.fnContext = undefined // 函数化组件上下文 this.fnOptions = undefined // 函数化组件配置项 this.fnScopeId = undefined // 函数化组件ScopeId this.key = data && data.key //只有绑定数据下存在,在diff的过程中可以提高性能 this.componentOptions = componentOptions // 通过vue组件生成的vnode对象,若是普通dom生成的vnode,则此值为空 this.componentInstance = undefined //当前组件实例 this.parent = undefined // vnode、组件的占位节点 this.raw = false //是否为原生HTML或只是普通文本 this.isStatic = false //静态节点标识 || keep-alive this.isRootInsert = true // 是否作为根节点插入 this.isComment = false // 是否为注释节点 this.isCloned = false //是否为克隆节点 this.isOnce = false //是否为v-once节点 this.asyncFactory = asyncFactory // 异步工厂方法 this.asyncMeta = undefined //异步Meta this.isAsyncPlaceholder = false //是否为异步占位 } //容器实例向后兼容的别名 get child (): Component | void { return this.componentInstance }}复制代码其他属性不重要,最主要的上tag、data、children、key、text这几个属性。VNode可以具体氛围以下几类
- TextVNode 文本节点。
- ElementVNode 普通元素节点。
- ComponentVNode 组件节点。
- EmptyVNode 没有内容的注释节点。
- CloneVNode 克隆节点,可以是以上任意类型的节点,唯一的区别在于isCloned属性为true我们先定义一个vnode
{ tag: 'div' data: { id: 'app', class: 'test' }, children: [ { tag: 'span', data:{ }, text: 'this is test' } ]}复制代码每一层对象都是一个节点。vnode
{ tag:'标签1', attrs:{ 属性key1:属性value1, 属性key2:属性value2, ... }, children:[ { tag:'子标签1', attrs:{ 子属性key1:子属性value1, 子属性key2:子属性value2, ... }, children:[ { .... } ] }, { tag:'子标签2', attrs:{ 子属性key1:子属性value1, 子属性key2:子属性value2, ... }, children:[ { .... } ] } ] }复制代码以嵌套递归的方式产生最后渲染成
<div id="app" class="test"> <span>this is test</span></div>复制代码Vue组件树建立起来的整个VNode树是唯一的。这意味着,手写render函数不能组件化
render: function (createElement) { var myVnode = createElement('p', 'hi') return createElement('div', [ myVnode, myVnode ])}复制代码compileToFunctions(template编译成render)首先在/src/platforms/web/compiler/index.js有定义compileToFunctions()方法,
// 设置编译的选项,不设置则使用默认配置,配置项比较多import { baseOptions } from './options'import { createCompiler } from 'compiler/index'// 通过模板导入配置生成AST和Renderconst { compile, compileToFunctions } = createCompiler(baseOptions)export { compile, compileToFunctions }复制代码先看导入的配置
export const baseOptions: CompilerOptions = { expectHTML: true, modules, directives, isPreTag, isUnaryTag, mustUseProp, canBeLeftOpenTag, isReservedTag, getTagNamespace, staticKeys: genStaticKeys(modules)}复制代码可以看到定义了compile和compileToFunctions,前者是AST语法树,后者是是编译好的renderFn
import { parse } from './parser/index' // 将 HTML template解析为ASTimport { optimize } from './optimizer' // 对AST优化标记处理,提取最大的静态树import { generate } from './codegen/index' // 根据 AST 生成 render 函数import { createCompilerCreator } from './create-compiler' //允许创建使用替代编译器,在这只使用默认部件导出默认编译器export const createCompiler = createCompilerCreator(function baseCompile ( template: string, options: CompilerOptions): CompiledResult { // parseHTML 的过程,导入配置,将template去掉空格,解析成AST ,最后返回AST元素对象 const ast = parse(template.trim(), options) console.log(ast) // 默认开始优化标记处理,否则不进行优化 if (options.optimize !== false) { optimize(ast, options) } // 拿到最终的code。里面包含renderFn和静态renderFn const code = generate(ast, options) console.log(code.render) //抛出 return { ast, render: code.render, staticRenderFns: code.staticRenderFns }})复制代码createCompilerCreator()接受一个函数参数,createCompiler用以创建编译器,返回值是compile以及compileToFunctions。
export function createCompilerCreator (baseCompile: Function): Function { return function createCompiler (baseOptions: CompilerOptions) { function compile ( template: string,//模板 options?: CompilerOptions // 编译配置 ): CompiledResult { // 将finalOptions的隐式原型__proto__指向baseOptions对象 const finalOptions = Object.create(baseOptions) const errors = [] const tips = [] finalOptions.warn = (msg, tip) => { (tip ? tips : errors).push(msg) } // 如果导入了配置就将配置进行合并 if (options) { // 合并分支模块 if (options.modules) { finalOptions.modules = (baseOptions.modules || []).concat(options.modules) } // 合并自定义指令 if (options.directives) { finalOptions.directives = extend( Object.create(baseOptions.directives || null), options.directives ) } // 合并其他配置 for (const key in options) { if (key !== 'modules' && key !== 'directives') { finalOptions[key] = options[key] } } } // 将传入的函数执行,传入模板和配置项,得到编译结果 const compiled = baseCompile(template, finalOptions) if (process.env.NODE_ENV !== 'production') { errors.push.apply(errors, detectErrors(compiled.ast)) } compiled.errors = errors compiled.tips = tips return compiled } return { compile, compileToFunctions: createCompileToFunctionFn(compile) } }}复制代码最后在compile()层级执行完毕后,将抛出编译函数
compile是一个编译器,它会将传入的template转换成对应的AST树、renderFn以及staticRenderFns函数
compileToFunctions,通过执行createCompileToFunctionFn(compile)得到,createCompileToFunctionFn()是带缓存的编译器,同时staticRenderFns以及renderFn会被转换成Funtion对象。最终将编译
不同平台有一些不同的options,所以createCompiler会根据平台区分传入一个baseOptions,会与compile本身传入的options进行合并得到最终的finalOptions。
export function createCompileToFunctionFn (compile: Function): Function { // 声明缓存器 const cache = Object.create(null) return function compileToFunctions ( template: string, options?: CompilerOptions, vm?: Component ): CompiledFunctionResult { // 合并配置 options = extend({}, options) const warn = options.warn || baseWarn delete options.warn //开发环境下尝试检测CSP,类似于用户浏览器设置,需要放宽限制否则无法进行编译,一般情况下可以忽略 if (process.env.NODE_ENV !== 'production') { // detect possible CSP restriction try { new Function('return 1') } catch (e) { if (e.toString().match(/unsafe-eval|CSP/)) { warn( 'It seems you are using the standalone build of Vue.js in an ' + 'environment with Content Security Policy that prohibits unsafe-eval. ' + 'The template compiler cannot work in this environment. Consider ' + 'relaxing the policy to allow unsafe-eval or pre-compiling your ' + 'templates into render functions.' ) } } } //有缓存的时候优先读取缓存的结果,并且返回 , const key = options.delimiters ? String(options.delimiters) + template : template if (cache[key]) { return cache[key] } // 没有缓存结果则直接编译 const compiled = compile(template, options) // 检查编译错误/提示 if (process.env.NODE_ENV !== 'production') { if (compiled.errors && compiled.errors.length) { warn( `Error compiling template:\n\n${template}\n\n` + compiled.errors.map(e => `- ${e}`).join('\n') + '\n', vm ) } if (compiled.tips && compiled.tips.length) { compiled.tips.forEach(msg => tip(msg, vm)) } } // 将代码转换成功能 const res = {} const fnGenErrors = [] // 将render转换成Funtion对象 res.render = createFunction(compiled.render, fnGenErrors) // 将staticRenderFns全部转化成Funtion对象 res.staticRenderFns = compiled.staticRenderFns.map(code => { return createFunction(code, fnGenErrors) }) //检查函数生成错误。只在编译器本身存在错误时才会发生,作者主要用于codegen开发使用 if (process.env.NODE_ENV !== 'production') { if ((!compiled.errors || !compiled.errors.length) && fnGenErrors.length) { warn( `Failed to generate render function:\n\n` + fnGenErrors.map(({ err, code }) => `${err.toString()} in\n\n${code}\n`).join('\n'), vm ) } } //最后存放在缓存中,下一次用可以进行读取 return (cache[key] = res) }}复制代码
这里有一点很有意思,
const cache = Object.create(null)
为什么不直接const cache = {}呢?我们感受下
最直观的感受就是隐式原型__proto__在null上面没有,首先const cache = {}会继承Object.prototype上所有的原型方法。而null不会,另一个使用Object.create(null)的理由是,使用for..in循环的时候会遍历对象原型链上的属性,使用Object.create(null)就不必再对属性进行检查了,当然,我们也可以直接使用Object.keys[]。除非你想你需要一个非常干净且高度可定制的对象当作数据字典或者想节省hasOwnProperty的一些性能损失。
HTML转RenderFn我们先写点代码
<div id="app"></div> <script> var vm = new Vue({ el:'#app', template:` <div @click="changeName()"> <span>{{name}}</span> <ul> <li v-for="(item,index) in like" :key="index">{{item}}</li> </ul> </div>`, data:{ name:'Seven', like:['旅游','电影','滑雪'] },methods:{ changeName(){ this.name = 'Floyd' } } }) </script>复制代码我们先看下他的AST语法树,
可能你看的有点头晕,没事,我们无需关心这个,抽象,能让你看懂了还叫抽象?我们再看下render函数
with(this){return _c('div',{on:{"click":function($event){changeName()}}},[_c('span',[_v(_s(name))]),_v(" "),_c('ul',_l((like),function(item,index){return _c('li',{key:index},[_v(_s(item))])}))])}复制代码为了方便大家看清楚结构,费会劲手动格式化以下
with(this) { return _c('div', { on: { "click": function ($event) { changeName() } } }, [ _c('span', [ _v(_s(name)) ]), _v(" "), _c('ul', _l( (like), function (item, index) { return _c('li', { key: index }, [ _v( _s(item) ) ] ) }) ) ] ) }复制代码可能有些人想着更看不懂,没事,这个逻辑可以看懂的。
_c( '标签名', { on:{//绑定 属性1:值, 属性2:值, ... } }, [//子节点 _c( '标签名', { on:{//绑定 子属性1:值, 子属性2:值, ... } }, [ //子标签... ] } ])复制代码将renderFn编译Vnode由于使用的with(this)语法,函数内有所变量都依赖于this变量,_c等同与this._c等同与vm._c,我们打印下vm._c
在JavaScript语言精粹一书中提到,尽量不要在你的函数内使用with()语法,它可能会让你的应用程序无法调试。但是尤雨溪这么使用,使用闭包将其封装在了函数内,无需担心外泄。
ƒ (a, b, c, d) { return createElement(vm, a, b, c, d, false); }/src/core/instance/render.js定义该方法// 将 createElement 函数绑定到这个实例上以便在其中获得renderFn上下文。vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)复制代码指向createElement()函数,它又指向_createElement(),该函数定义在/src/core/vdom/create-element.js。最终返回的是一个Vnode。该函数定义可以在本文目录里跳转查看其他的函数我们可以在 /rc/core/instance/render-helper/index.js里找到相关定义
export function installRenderHelpers (target: any) { target._o = markOnce // v-once静态组件 target._n = toNumber // 判断是否数字,先parse再isNAN target._s = toString // 需解析的文本,之前在parser阶段已经有所修饰 target._l = renderList // v-for节点 target._t = renderSlot // slot节点 target._q = looseEqual // 检测两个变量是否相等 target._i = looseIndexOf // 检测数组中是否包含与目标变量相等的项 target._m = renderStatic // 渲染静态内容 target._f = resolveFilter // filters处理 target._k = checkKeyCodes // 从config配置中检查eventKeyCode是否存在 target._b = bindObjectProps // 合并v-bind指令到VNode中 target._v = createTextVNode // 创建文本节点 target._e = createEmptyVNode // 注释节点 target._u = resolveScopedSlots // 处理ScopedSlots target._g = bindObjectListeners // 处理事件绑定}复制代码createElementvar SIMPLE_NORMALIZE = 1;var ALWAYS_NORMALIZE = 2;function createElement ( context, tag, data, children, normalizationType, alwaysNormalize) { // 兼容不传data的情况 if (Array.isArray(data) || isPrimitive(data)) { normalizationType = children; children = data; data = undefined; } // 如果alwaysNormalize是true // 那么normalizationType应该设置为常量ALWAYS_NORMALIZE的值 if (isTrue(alwaysNormalize)) { normalizationType = ALWAYS_NORMALIZE; } // 调用_createElement创建虚拟节点 return _createElement(context, tag, data, children, normalizationType)}function _createElement ( context, tag, data, children, normalizationType) { /* * 如果存在data.__ob__,说明data是被Observer观察的数据 * 不能用作虚拟节点的data * 需要抛出警告,并返回一个空节点 * * 被监控的data不能被用作vnode渲染的数据的原因是:data在vnode渲染过程中可能会被改变,这样会触发监控,导致不符合预期的操作 * */ if (isDef(data) && isDef((data).__ob__)) { "development" !== 'production' && warn( "Avoid using observed data object as vnode data: " + (JSON.stringify(data)) + "\n" + 'Always create fresh vnode data objects in each render!', context ); return createEmptyVNode() } // 当组件的is属性被设置为一个false的值 if (isDef(data) && isDef(data.is)) { tag = data.is; } // Vue将不会知道要把这个组件渲染成什么,所以渲染一个空节点 if (!tag) { return createEmptyVNode() } // 如果key是原始值,就警告key不能是原始值,必须string或者是number类型的值 if ("development" !== 'production' && isDef(data) && isDef(data.key) && !isPrimitive(data.key) ) { { warn( 'Avoid using non-primitive value as key, ' + 'use string/number value instead.', context ); } } // 作用域插槽 // 如果子元素是数组并且第一个是renderFn,就将其转移到scopedSlots if (Array.isArray(children) && typeof children[0] === 'function' ) { data = data || {}; data.scopedSlots = { default: children[0] }; children.length = 0; } // 根据normalizationType的值,选择不同的处理方法 if (normalizationType === ALWAYS_NORMALIZE) { children = normalizeChildren(children); } else if (normalizationType === SIMPLE_NORMALIZE) { children = simpleNormalizeChildren(children); } var vnode, ns; //如果标签名是string类型 if (typeof tag === 'string') { var Ctor; // 取到如果当前有自己的vnode和命名空间 或者 获取标签名的命名空间 ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag); // 判断是否为保留标签 if (config.isReservedTag(tag)) { // 如果是保留标签,就创建一个这样的vnode vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined, undefined, context );// 如果不是保留标签,那么我们将尝试从vm实例的components上查找是否有这个标签的定义,自定义组件 } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) { // 如果找到了这个标签的定义,就以此创建虚拟组件节点 vnode = createComponent(Ctor, data, context, children, tag); } else { // 保底方案,正常创建一个vnode // unknown or unlisted namespaced elements // check at runtime because it may get assigned a namespace when its // parent normalizes children vnode = new VNode( tag, data, children, undefined, undefined, context ); } } else { // direct component options / constructor // 当tag不是字符串的时候,就是组件的构造类,直接创建 vnode = createComponent(tag, data, context, children); } // 如果vnode是数组,直接返回。 if (Array.isArray(vnode)) { return vnode //如果有vnode } else if (isDef(vnode)) { // 如果有namespace,就应用下namespace,然后返回vnode if (isDef(ns)) { applyNS(vnode, ns); } // 如果定义了数据,就将其深度遍历,针对于class或者是style if (isDef(data)) { registerDeepBindings(data); } return vnode } else { //保底创建空VNode return createEmptyVNode() }}复制代码流程图看下
new Vue找到src/core/instance/index.js
创建Vue函数,并且检测当前是不是开发环境,如果Vue不是通过new实例化的将警告。然后初始化this._init(options)。为什么(this instanceof Vue)这一句可以判断是否使用了new操作符?
已new来调用构造函数会经历4个步骤:
- 创建一个新对象;
- 将构造函数的作用域赋给新对象(因此this 就指向了这个新对象);
- 执行构造函数中的代码(为这个新对象添加属性);
- 返回新对象。而instanceof用来检测Vue构造函数的prototype是否存在于this的原型链上,换句话说,如果使用new实例化的时候,this就指向了这个新创建的对象,这时this instanceof Vue这句话的意思就是判断新创建的对象是否是Vue类型的,也就相当于判断新实例对象的constructor是否是Vue构造函数。
未完待续...持续更新
作者:anfunnysoul
链接:https://juejin.im/post/5b28f54be51d45587f49fd41
|
|