「mini-vue」| 1、探索 vue 数据首次渲染流程

335 阅读5分钟

Hello 大家好!我是壹甲壹!

本文是手写 mini-vue 系列的第一篇,本篇主要是探索 Vue 中数据首次渲染流程。阅读完本文,你将了解以下内容:

  • Vue 中是如何实现响应式数据的?
  • Vue 中是如何监测数组的变化的?
  • Vue 中模版编译是如何实现的?
  • Vue 中实例首次渲染如何实现的?

本文 Vue 源码版本:2.6.11 ** mini-vue 仓库地址:mini-vue **,欢迎 start 👏👏

好了,让我们步入正题开始吧,墙裂建议搭配代码阅读

一、开发环境搭建

  • 项目初始化
mkdir vue-source
cd vue-source
npm init -y
  • 安装第三方依赖
npm i -D @babel/core @babel/preset-env rollup rollup-plugin-babel rollup-plugin-serve

采用的 rollupjs 模块打包器,一个 JavaScript 模块打包器,可以将小块代码编译成大块复杂的代码

  • 入口文件编写 src/index.js
// src/index.js
function Vue (){
  //...
}
export default Vue
  • 首页文件 public/index.html
<!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="../dist/vue.js"></script>
  <script>
    const vue = new Vue()
    console.log(vue)
  </script>
</body>
</html>
  • rollup 配置文件 rollup.config.js
import babel from 'rollup-plugin-babel'
import serve from 'rollup-plugin-serve'
export default {
  input: './src/index.js',
  output: {
    format: 'umd', // amd commonjs规范  默认将打包后的结果挂载到window上
    file: 'dist/vue.js', // 打包出的vue.js 文件  new Vue
    name: 'Vue',
    sourcemap: true
  },
  plugins: [
    babel({ // 解析es6 -》 es5
        exclude: "node_modules/**" // 排除文件的操作 glob  
    }),
    serve({ // 开启本地服务
        open: true,
        openPage: '/public/index.html',
        port: 3000,
        contentBase: ''
    })
  ]
}
  • 配置 script 运行脚本
"scripts": {
    "serve": "rollup -c -w"
 }
  • 运行项目
npm run serve

此次,开发环境搭建完成,详细代码可访问 mini-vue chapter-01 查看

二、Vue 数据初始化

我们都知道在 Vue 2.x 版本中,使用的是 Object.defineProperty 来对数据进行监测的,接下来,看具体怎么实现的吧

从创建一个简单的 Vue 实例开始:

const vue = new Vue({
	el: '#app',
  data () {
  	return {
    	use: 'tom',
      age: 23,
      taskInfo: {
        id: '123321',
        name: ''
      }
    }
  }
  // ...
})

data 属性在根组件中,可以直接写成一个对象,在组件中,推荐写成函数,返回一个对象,这样可以保证每个组件实例中的数据互不干扰。

在构造函数 Vue 中,我们就可以拿到传递的参数了,现在,我们需要一些做一些初始化工作

// src/index.js
function Vue(options) {
	console.log(options)
}
export default Vue

2.1 _init(), 内部初始化

我们需要在 Vue 原型上定义一个方法 _init,用来完成一些初始化的工作

// src/index.js
import {initMixin} from './init.js'
function Vue(options) {
  this._init(options)
}

initMixin(Vue)

ps: 将初始化代码逻辑抽离出去,有利于后期扩展

// src/init.js
import {initState} from './state.js'
export function initMixin(Vue) {
  Vue.prototype._init = function (options) {
    const vm = this
    vm.$options = options // 绑定用户传入的参数
    initState(vm) // 初始化状态
  }
}

在初始化函数 _init 中,不仅需要对数据做状态处理,后续还需要展开其它操作,例如挂载等,所以将状态初始化逻辑抽离到单独文件中

2.2 initState(), 状态初始化

// src/state.js
export function initState(vm) {
  const opts = vm.$options 

  if (opts.props) {
    initProps(vm)
  }
  if (opts.data) {
    initData(vm)
  }
}
// 初始化 props
function initProps(){}
// 初始化 data
function initData(){}

因为 $options 存在不同的属性,有 el, props, data, computed, watch 等,所以针对不同的属性,采取不同的初始化函数,我们先以 initData 函数展开介绍

// src/state.js
import {observer} from './observer/index'
function initData(vm) {
  let data = vm.$options.data
  data = typeof data === 'function' ? data.call(vm) : data
  observer(data)
}

因为 data 可以是对象,也可以是个函数,若是函数,执行函数获取函数返回值。ps: data.call(vm), 传入 vm 是保证 data 函数中的 this 为当前 vue 实例

通过 observer 函数对 data 进行监测,为了代码不耦合,我们将监测函数抽离出来

2.3 observer(),数据监测

2.3.1 普通监测

observer 函数中首先会判断 data 是否为对象,isObject 判断逻辑如下

// src/utils/index.js
export function isObject (obj) {
  return (obj && typeof obj === 'object')
}

仅当 data 是对象,才会进行数据监测。通过 new Observer(data) 一个实例,就可以对 data 当中的所以属性定义 get 和 set 方法 (ps:暂时不考虑数组,数组情况特殊,后续展开)

// src/observer/index.js
import {isObject} from '../utils/index'

export function observer(data) {
  if (!isObject(data)) {
    return false
  }
  return new Observer(data)
}

class Observer{
  constructor (data) {
     this.walk(data) // 对数据一步一步处理
  }
  walk (data) {
    Object.keys(data).forEach(key => {
      defineReactive(data, key, data[key])
    })
  }
}

function defineReactive(target, key, value) {
  observer(value) // 当 value 是个对象,需要迭代继续监测
  Object.defineProperty(target, key, {
    get () {
      return value
    },
    set (newValue) {
      if (newValue === value) return
      observer(newValue) // 当设置的 newValue 是个对象,也需要对其监测
      value = newValue
    }
  })
}

现在,我们可以测试下,数据监测后的结果

// public/index.html
<script>
  const vue = new Vue({
    el: '#app',
    data () {
      return {
        use: 'tom',
        age: 23,
        taskInfo: {
          id: '123321',
          name: ''
        }
      }
    }
  })
  console.log(vue)
</script>

打印结果

我们发现,监测过后的 data 并没有挂载到当前实例上,所以,添加以下代码就 OK了

// src/state.js
function initData(vm) {
  vm.$data = data = typeof data === 'function' ? data.call(vm) : data
}

此时,通过 vm.$data 就可以访问到观测监听后的数据了,打印结果如下

现在我们看到每个属性都增加了 get 和 set 方法。⚠️注意,**taskInfo**** 的属性值为对象,其中的属性也会被监测到。** 前面提到数组比较特别,我们可以先看下,当前在 data 增加个属性 hobbies,值为 ['game', 'read', 'run'],观测结果如下

此时,数组中每个索引都被增加上了 get  和 set 方法,但 Vue 中并没有使用该方法来监听数组,原因有二

  • 1、当我们数组长度很大时,每个索引都进行监测,十分影响性能
  • 2、开发中并不常用 arr[10]=xxx 这种通过下标来修改数组,而是使用 push ,shift, splice 等方法操作数组

所以,数组的监测,需要区别对待

2.3.2 数组监测

在 Vue 中,对数组的监测采用的是函数劫持,将那些会改变数组的方法,进行重写。

// src/observer/index.js
import {arrayMethods} from './array'
class Observer{
  constructor (data) {
    if (Array.isArray(data)) {
      data.__proto__ = arrayMethods;
      this.observerArray(data) // 对象数组
    } else {
      this.walk(data) // 对数据一步一步处理
    }
  }
  observerArray(data) {
    for (let i = 0; i < data.length; i++) {
      observer(data[i])
    }
  }
}

👆当 data 是个数组时,重写数组的原型,同时当 data 是个对象数组时,我们需要监测数组中的每一个对象

// src/observer/array.js
let oldArrayMethods = Array.prototype

export let arrayMethods = Object.create(oldArrayMethods)

const methods = [
  'push',
  'pop',
  'shift',
  'unshift',
  'sort',
  'reverse',
  'splice'
]

methods.forEach(method => {
  arrayMethods[method] = function (...args) {
    const result = oldArrayMethods[method].apply(this, args)
    return result
  }
})

现在,我们对能够改变原数组的 7 中方法进行了重写,在重写函数中,首先会执行该方法默认的逻辑并拿到返回值,同时,push、unshift、splice 方法可以向原数组中添加新的子项,所以,我们也要监测新增加的子项

// src/observer/array.js
methods.forEach(method => {
  arrayMethods[method] = function (...args) {
    const ob = this.__ob__
    const result = oldArrayMethods[method].apply(this, args)
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break;
      case 'splice':
        inserted = args.slice(2)
        break;
    }
    inserted && ob.observerArray(inserted)
    return result
  }
})

在对数组中新增加的子项进行监测时,需要注意的是,监测方法 observerArray 是通过 this.__ob__ 属性获取到的,此处的 this 指向的是当前操作的数组。默认数组上不存在该属性,所以我们需要添加下面的代码

// src/observer/index.js
class Observer{
  constructor (data) {
    // 标识
    Object.defineProperty(data, '__ob__', {
      enumerable: false,
      configurable: false,
      value: this
    })
  }
  observerArray(data) {
    for (let i = 0; i < data.length; i++) {
      observer(data[i])
    }
  }
  //.....
}

此时,_ ob_ 属性相对于一个标识符,无论 data 是数组还是对象,都会添加上。使用 defineProperty 定义该属性,而非通过 data.__ob__= this 直接赋值,是为了避免该属性会通过 Object.keys() 遍历出来,从而被监测到。

现在,可以测试下,给对象数组增加一个新的对象子项,新的子项是否会被监听到

<script>
  const vue = new Vue({
    el: '#app',
    data () {
      return {
        bookList: [
          {
            name: 'book_111',
            price: 11
          },
          {
            name: 'book_222',
            price: 22
          }
        ]
      }
    }
  })
  vue.$data.bookList.push({
    name: 'book_333',
    price: 33
  })
  console.log(vue)
 </script>

输出结果如下

数组中默认存在的对象都被监测,同时通过 push 新增的子项也被监测到

2.3.3 数据代理

前面使用到 vue.$data.bookList.push 新增数据,但更期望的是通过实例直接访问操作 data 中的数据,像 vue.bookList.push 这样,所以需要做个数据代理

// src/util.js 
export function proxy(vm, data, key) {
    Object.defineProperty(vm, key, {
        get() {
          	// vm.a => vm.$data.a
            return vm[data][key];
        },
        set(newValue) {
            // vm.a = 100;  => vm.$data.a = 100;
            vm[data][key] = newValue
        }
    })
}
// src/state.js
import { proxy } from './util.js'
function initData(vm) { // 数据初始化
    let data = vm.$options.data;
    vm.$data = data = typeof data == 'function'?data.call(vm):data;
    for(let key in data){
        proxy(vm,'$data',key);
    }
    observe(data); 
}

此时就在 vm[key]vm.$data[key] 之间做了数据代理,操作 vm[key] 就是在操作 vm.$data[key]

2.4 小结

至此,当属性值是对象或数组是,我们都可以对其进行监测了,本小节完整代码请参考 mini-vue chapter-02

三、模版编译

模版编译的核心是将 template 转换成 render,在讨论模版编译之前,我们知道通过指定 el 属性,或者调用 $mount 方法,可将 Vue 实例挂载到页面上,而页面上具体会渲染什么内容呢?

所以在内部 $mount 函数中,会进行以下判断:

  • 首先判断是否存在 render 函数?
  • 当不存在 render 时,判断 template 属性是否存在,当 template 不存在而 el 存在时,会将 el.outerHTML 作为 template
// src/init.js
Vue.prototype.$mount = function (el) {
    const vm = this
    const options = vm.$options
    // 获取真实 dom, 并挂载到 vm 上
    el = document.querySelector(el)
    vm.$el = el
    
    if (!options.render) {
      let template = options.template
      if (!template && el) {
        template = el.outerHTML
      }
      const render = compileToFunctions(template) 
      options.render = render
    }
    mountComponent(vm, el) 挂载组件,后续解析
}

接下来,需要实现 compileToFunctions 方法将 template 转换成 render 函数 (ps: template 其实就是 html 代码),具体分成以下几个步骤

  • 解析 html 代码生成 ast 语法树
  • ast 进行静态节点标记 (可提高 VNode Diff 效率)
  • ast 转换成字符串,字符串中嵌入 _c, _v, _s 等方法,分别用来描述 dom节点文本节点、以及变量
  • 将字符串通过 new Functionwith 转换成 render 函数
// src/complier/index.js

import { parseHtml } from "./parseHtml"
import { generate } from "./generateCode"

export function compileToFunctions(template) {
  // 模版编译,将html模版 => render 函数
  // 1、html 代码转换成 ast 语法树
  const ast =  parseHtml(template)
  // 2、标记静态节点
  // 3、ast 转换成字符串 strCode
  const strCode = generate (ast)
  // 4、转换成 render 函数
  const render = new Function(`with(this){return ${strCode}}`)
  return render
}

3.1 parseHtml

以👇 HTML 代码为例,探索如何生成 ast 语法树

`<div id="app" style="color:red">Hello <span>World, {{name}}</span> </div>`

ast 树的生成核心就在于使用正则对 html 字符串进行匹配,大致步骤如下

  • 当匹配到开始标签 <div 时,会创建一个对象 match 标识匹配到的标签 div, 匹配成功后, 删除已匹配的内容
  • 在匹配开始标签中的属性 id="app" style="color:red",并将属性存储到 match.attr 数组中,删除已匹配内容
  • 当匹配到开始标签结束符 > 时,表示整个开始标签匹配完成,删除已匹配内容
  • 接着就是匹配文本 Hello , 处理文本,直到最后匹配到 </div> 结束标签,处理结束标签
  • 此时,整个html 就匹配完成
// src/complier/parseHtml.js

export function parseHtml(html){
	let root
  while(html) {
  	// 1、判断是否以 `<` 开头
    let textEnd = html.indexOf('<')
    if (textEnd === 0) {
    	// 开头是标签
    }
    if (textEnd > 0) {
    	// 开头是文本
    }
  }
  return root // 返回 ast
}

3.1.1 匹配开始标签

当 textEnd === 0 时,html 字符串开头可能是开始标签

, 也有可能是结束标签
, 我们先匹配开始标签

// src/complier/parseHtml.js
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*` // 匹配标签名
const qnameCapture = `((?:${ncname}\\:)?${ncname})` // 匹配类似 my:xxx 的标签名
const startTagOpen = new RegExp(`^<${qnameCapture}`) // 匹配开始标签

export function parseHtml(html){
	let root
  while(html) {
  	// 1、判断是否以 `<` 开头
    let textEnd = html.indexOf('<')
    if (textEnd === 0) {
    	// 开头是标签
      const startTagMatch = parseStartTag()
      if (startTagMatch) {
        start(startTagMatch); // 处理标识开始标签的 match 对象,后续介绍
        continue;
      }
    }
    if (textEnd > 0) {
    	// 开头是文本
    }
  }
  
  // 删除匹配成功的字符串
  function advance (n) {
  	html = html.substring(n).trim()
  }
  
  function parseStartTag() {
  	const startTag = html.match(startTagOpen)
    
    if (startTag) {
      const match = {
        tagName: startTag[1],
        attrs: []
      }
      // 匹配成功后,就需要在 html 中删除已经匹配的字符长度
      advance(startTag[0].length)
  	}
  }
  return root // 返回 ast
}

匹配成功后的 startTag 是个数组,输出如下

["<div", "div", index: 0, input: "<div id="app" style="color:red"><p>hello wor...</div>", groups: undefined]

通过 match 标识开始标签,同时使用 advance 函数删除已经匹配的 <div ,此时 html 显示如下

`id="app" style="color:red">Hello <span>World, {{name}}</span> </div>`

接下来,就需要匹配开始标签中的属性了

3.1.2 匹配标签属性

有些开始标签中并没有属性,所以需要判断是否匹配到属性了,同时当我们匹配到开始标签中的 > 结束字符时,也就表明所有属性匹配完成

// src/complier/parseHtml.js
const startTagClose = /^\s*(\/?)>/ // 匹配开始标签的结束符 >
// 匹配属性
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/

function parseStartTag() {
  const startTag = html.match(startTagOpen)

  if (startTag) {
    const match = {
      tagName: startTag[1],
      attrs: []
    }
    // 匹配成功后,就需要在 html 中删除已经匹配的字符长度
    advance(startTag[0].length)

    let end
    let attr
    while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
      match.attrs.push({
        name: attr[1],
        value: attr[3] || attr[4] || attr[5]
      })
      advance(attr[0].length)
    }
    if (end) {
      advance(end[0].length)
      return match
    }
  }
}

大家可能会疑惑为啥属性值是 attr[3] || attr[4] || attr[5] ?🤔,原因是因为我们有以下三种方式写属性

<div id="app" class='wrap' data-xxx=yyy></div>

现在,parseStartTag 函数就会返回匹配开始标签已经标签中的属性值,通过 match 对象标识,match 对象会使用 start 方法处理,后续介绍。此时,html 字符串剩余字符如下,需要开始匹配文本了

`Hello {{name}} <span>World</span> </div>`

3.1.3 匹配文本

在本次 while 循环中,textEnd = html.indexOf('<') 的值肯定大于 0,文本的范围正好是 0 ~ textEnd,所以匹配文本逻辑如下

if (textEnd > 0) {
      // 从 0 ~ textEnd 表示的就是文本
    let text = html.substring(0, textEnd)
    if (text) {
      chart(text) // 处理文本节点,后续介绍
      advance(text.length)
    }
}

此时 ,html 剩余字符串为

`<span>World</span></div>`

<span>World 跟前面相同逻辑,不再赘述。接下来就是需要匹配结束标签 </span>

3.1.4 匹配结束标签

结束标签以 < 开始,所以也会进入到 textEnd === 0 的逻辑中

// ...
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) // 匹配结束标签

if (textEnd === 0) {
  // 可能是开始标签
  // ...

  // 可能是结束标签
  const endTagMatch = html.match(endTag)
  if (endTagMatch) {
    end(endTagMatch[1]) // 处理结束标签,后续展开
    advance(endTagMatch[0].length)
    continue;
  }
}

剩余标签 </div> 也是结束标签,相同逻辑,不再赘述。 此时,整个 html 解析完成,我们在 start 、chart、end 三个方法中可获取成功匹配的开始标签、文本、结束标签

3.1.5 处理函数

返回的 ast 是一个树形结构,我们需要在处理函数中生成树形结构,大致流程如下

  • 使用一个栈来维护节点之间的层级关系,一个变量 currentEle 标识正在处理的DOM节点
  • 匹配到开始标签时,在 start 函数中,进行入栈操作,同时 currentEle 被赋值为开始标签元素
  • 匹配到文本,在 chart 函数中,文本作为 currentEle 的子节点
  • 匹配到结束标签,出栈,并将 currentEle 设置为当前栈中最后一个

具体逻辑如下

  • start
export function parseHtml(html){
  let root = ''
  let currentEle = '' //标记当前正在处理的DOM 节点
  let stackList = [] // 用栈来为何 DOM 节点之间的层级关系
    
  function createAstElement (tagName, attrs) {
    return {
      tag: tagName,
      type: 1, // dom节点类型都为 1
      children: [],
      attrs,
      parent: null
    }
  }

  function start({tagName, attrs}) {
    let element = createAstElement(tagName, attrs)
    if (!root) {
      root = element
    }
    currentEle = element
    stackList.push(element) // 处理开始标签进栈,处理结束标签出栈
  }
  return root
}

start 函数中,使用传入的标签创建 ast 元素,将元素入栈,并标识为 currentEle

  • chart
function chart(text){
  text = text.trim()
  if (text) {
    currentEle.children.push({
      type: 3,
      text,
      parent: currentEle
    })
  }
}

文本 text 会作为当前 currentEle 的字元素,并将文本标识为type = 3

  • end
function end(tagName){
  let element = stackList.pop()
  if (element.tag === tagName) {
    currentEle = stackList[stackList.length - 1] 
    if (currentEle) {
      // 双向绑定
      element.parent = currentEle 
      currentEle.children.push(element)
    }
  }
}

还是以👇 html为例,探索 end 中的逻辑

`<div id="app" style="color:red">Hello {{name}}<span>World</span> </div>`
  • 匹配到 <div , 进栈,currentEle = div
  • 匹配到 <span, 进栈,currentEle = span
  • 匹配到 </span> , 此时 stackList = [div, span] , 将 span 出栈, 并设置 currentEle = stackList[stackList.length - 1] 
  • 此时,当 currentEle 存在时,会将 span 作为 currentEle 的子元素

至此,解析 html 生成 ast 语法树已经完成,返回的 ast 打印如下。 本小节完整代码请参考 mini-vue chapter-02

3.2 generate

generate 函数作用是将 ast 对象转换成字符串,字符串中嵌入 _c, _v, _s 等实例方法, 分别用来标识 DOM 节点、文本节点、以及变量。期望生成的字符串例如👇

`_c(
	'div',
	{id: "app",style: {"color":"red"}},
	_v("Hello "+_s(name)),
  _c('span',null, _v("World ")),
)`

可以看出,当 ast 中存在标签时,会使用 _c 方法标识,该方法参数是 _c(tag, attrs, ...children), 当遇到文本 Hello {{name}},使用 _v 进行标识,当文本中存在变量 {{name}},会使用正则匹配出变量,并使用 _s 进行标识

// src/complier/generateCode.js

export function generate(ast) {
  const children = genChildren(ast.children)
  const attrs = genPropertyData(ast.attrs)
  let strCode = `_c('${ast.tag}',${attrs ? attrs : 'undefined'},${children ? children : ''})`
  return strCode
}

function genChildren() {}
function genPropertyData() {}

attrschildren 的处理逻辑都拆分到不同函数中处理。先看 children 处理逻辑吧

3.2.1 处理 children

// src/complier/generateCode.js
function genChildren (children) {
  if (!(children && children.length)) return false
  return children.map(child => gen(child)).join(',')
}

function gen() {}

因为子节点可能是文本节点或标签节点,所以抽离到 gen 函数中处理,

// src/complier/generateCode.js

const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // 匹配大括号 {{}}
function gen(node) {
	if (node.type === 1) { // DOM 节点
    return generate(node)
  } else {
    // 文本节点, eg: `Hello {{name}}`, 需要匹配大括号里面的值
    // _v('Hello'+ _s(name))
    const {text} = node
    if (!defaultTagRE.test(text)) {
      // 纯文本
      return `_v(${JSON.stringify(text)})`
    } else {
      let tokens = []
      let match, index
      let lastIndex = defaultTagRE.lastIndex = 0;
      while(match = defaultTagRE.exec(text)) {
        index = match.index
        if (index > lastIndex) {
          tokens.push(JSON.stringify(text.slice(lastIndex,index)))
        }
        tokens.push(`_s(${match[1].trim()})`)
        lastIndex = index + match[0].length
      }
      if (lastIndex < text.length) {
        tokens.push(JSON.stringify(text.slice(lastIndex)))
      }
      return `_v(${tokens.join('+')})`;
    }
  }
}

当子节点是标签时,直接递归调用 generate 函数即可;当是文本时,需要匹配出文本中可能存在的变量。 以将文本 Hello {{name}} World 转换成 _v('Hello'+ _s(name) + 'World') 为例,探索如何匹配出变量

我们先看下正则 exec 方法吧

**exec() **方法在一个指定字符串中执行一个搜索匹配。返回一个结果数组或 null。 在设置了 globalsticky 标志位的情况下(如 /foo/g or /foo/y),JavaScript RegExp 对象是有状态的。他们会将上次成功匹配后的位置记录在 lastIndex 属性中。使用此特性,exec() 可用来对单个字符串中的多次匹配结果进行逐条的遍历(包括捕获到的匹配)

变量 lastIndex 标识每次匹配开始的位置,将匹配成功返回的 match 打印输出如下

 ["{{name}}", "name", index: 6, input: "Hello {{name}} World", groups: undefined]

match[1] 就是要匹配的变量使用 _s 进行包裹,同时 match.index 标识匹配成功的位置为 6,那么 0 ~ 6 就是普通文本 Hello ,更新下次匹配开始的位置 lastIndexindex + match[0].length 等于 14

while 的第二次循环匹配不成功返回 null , 结束循环,此时 lastIndex 到文本最后,表示普通文本  World ,将所以匹配的数据通过拼接就可得到最终的字符串

3.2.2 处理 attrs

属性的处理相对简单点,不过需要注意的是 style 属性值可能是多个 "width: 20px; color: red;" ,需要转换成对象 {width: '20px', color: 'red'}

// src/complier/generateCode.js
function genPropertyData (attrs) {
  if (!(attrs && attrs.length)) return false
  let str = ''
  attrs.forEach(attr => {
    if (attr.name === 'style') {
      let obj = {}
      attr.value.split(';').map(item => {
        let [k, v] = item.split(':')
        obj[k] = v
      })
      attr.value = obj
    }
    str += `${attr.name}: ${JSON.stringify(attr.value)},`
  });
  return `{${str.slice(0, -1)}}` // 删除最后一个 ,
}

至此,ast 转换成字符串已经完成

3.3 render

通过将字符串通过 new Functionwith 拼接,就可组成 render 函数

const render = new Function(`with(this){return ${strCode}}`)

因为 strCode 中存在实例上的变量,所以后面需要调用 render.call(vm) 确保 this 正确。

3.4 小结

至此,整个模版编译过程结束,然而在开发时尽量不要使用 template,因为将 template 转化成 render 方法需要在运行时进行编译操作会有性能损耗,同时引用带有 compiler 包的 vue 体积也会变大,而默认vue项目中引入的vue.js是不带有compiler模块的。

四、组件挂载

让我们回到 Vue.prototype.$mount 方法中,生成 render 函数后,就需要挂载组件了

// src/init.js
Vue.prototype.$mount = function (el) {
    const vm = this
    const options = vm.$options
    // 获取真实 dom, 并挂载到 vm 上
    el = document.querySelector(el)
    vm.$el = el
    
    if (!options.render) {
      let template = options.template
      if (!template && el) {
        template = el.outerHTML
      }
      const render = compileToFunctions(template) 
      options.render = render
    }
    mountComponent(vm, el) 挂载组件,后续解析
}

mountComponent 中首先调用 _render() 方法返回创建 VNode, 然后调用 _update 方法创建真实 DOM, 更新到页面上

// src/lifeycle.js
export function mountComponent (vm) {
	vm._update(vm._render())
}

4.1 renderMixin

现在我们需要创建 _render 方法,在该方法中会调用之前生成的 render 函数,同时不要忘记,render 函数执行过程中,会调用 _c, _v, _s 等还未创建的方法

// src/vdom/index.js

export function renderMixin (Vue) {
  // DOM节点
  Vue.prototype._c = function () {
    return createElement(...arguments)
  }
  // Text节点
  Vue.prototype._v = function (text) {
    return createTextElement(text)
  }
  // 变量
  Vue.prototype._s = function (val) {
    return val == null ? '' : (typeof val == 'object') ? JSON.stringify(val) : val;
  }

  Vue.prototype._render = function () {
    // 调用真实的 render 方法,
    const vm = this
    const render = vm.$options.render
    const vNode = render.call(vm) // 确保this正确
    return vNode
  }
}

当 render 执行,需要调用 _c, _v, _s 方法创建出 VNode 节点

// src/vdom/index.js

function createElement (tag, data = {}, ...children) {
  return createVNode(tag, data, data.key, children)
}
function createTextElement (text) {
  return createVNode(undefined,undefined,undefined,undefined,text);
}
function createVNode (tag,data,key,children,text) {
  return {
    tag,
    data,
    key,
    children,
    text
  }
}

此时,调用 _render() 返回的 VNode 打印输出如下

然后在实例挂载、渲染之前,我们就应该先创建这些方法,所以在入口文件 src/index.js 文件导入 renderMixin 并执行

import {initMixin} from './init.js'
import { renderMixin } from './vdom/index.js'

function Vue(options) {
  // 内部初始化操作
  this._init(options) 
}

initMixin(Vue) // 
renderMixin(Vue) // 方法混合

export default Vue

4.2 patch

调用 vm._render() 返回的 VNode 会传递到 vm._update() 方法中, _update 方法定义在 lifecycleMixin 中,我们也需要在入口文件 src/index.js 文件导入 lifecycleMixin 并执行

// src/lifeycle.js

export function lifecycleMixin (Vue) {
  Vue.prototype._update = function (vNode) {
    const vm = this
    patch(vm.$el, vNode)
  }
}
import {initMixin} from './init.js'
import { renderMixin } from './vdom/index.js'
import { lifecycleMixin } from './lifecycle.js'

function Vue(options) {
  // 内部初始化操作
  this._init(options) 
}

initMixin(Vue) // 
lifecycleMixin(Vue) // 生命周期混合
renderMixin(Vue) // 方法混合

export default Vue

接下来,我们就需要实现 patch 方法,首次渲染时,将传入的 VNode 转换生成真实 DOM, 替换掉 el 对应的真实 DOM 即可

export function patch (oldVNode, newVNode) {
  let newEl = createEl(newVNode)
  let parentEl = oldVNode.parentNode
  parentEl.insertBefore(newEl, oldVNode.nextSibling)
  parentEl.removeChild(oldVNode)
  // 非首次渲染,需要进行 VNode Diff
}

function createEl (vNode) {
  let {tag,key,children,text} = vNode
  if (tag && typeof tag === 'string') {
    vNode.el = document.createElement(tag)
    updateElProperties(vNode)
    children.forEach(child => {
      vNode.el.appendChild(createEl(child))
    })
  } else {
    vNode.el = document.createTextNode(text)
  }
  return vNode.el
}

function updateElProperties (vNode) {
  console.log(vNode)
  const {el, data = {}} = vNode
  for (const key in data) {
    if (data.hasOwnProperty(key)) {
      if (key === 'style') {
        const styleObj = data.style
        for (const v in styleObj) {
          el.style[v] = styleObj[v]
        }
      } else if (key === 'class'){
        el.className = data.class
      } else {
        el.setAttribute(key, data[key])
      }
    }
  }
}

4.3 小结

至此,组件挂载完结,我们可以测试下

<!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" style="color:red; border: 1px solid blue; width: 260px;" > 
    <p>Hello {{name}} World</p>
    <li style="color:green">
        {{book.name}}
    </li>
    <li>
        {{book.price}}
    </li>
  </div>
  <script src="../dist/vue.js"></script>
  <script>
    const vue = new Vue({
      el: '#app',
      data () {
        return {
          name: '掘金',
          book: {
            name: 'Hello Mini Vue',
            price: 1
          }
        }
      }
    })
  </script>
</body>
</html>

首次渲染截图如下

五、总结

至此,探索 Vue 实例首次渲染流程结束,但还有很多内容等着处理

  • 依赖收集、实现响应式
  • 解析 v-for v-model 等指令
  • computed,watch,生命周期 hook 等
  • VNode Diff ......

生活就是不断挖坑、填坑

仓库地址:mini-vue,欢迎 start 👏👏