手写 Vue2 系列 之 编译器

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

image

前言

接下来就要正式进入手写 Vue2 系列了。这里不会从零开始,会基于 lyn-vue 直接进行升级,所以如果你没有阅读过 手写 Vue 系列 之 Vue1.x,请先从这篇文章开始,按照顺序进行学习。

都知道,Vue1 存在的问题就是在大型应用中 Watcher 太多,如果不清楚其原理请查看 手写 Vue 系列 之 Vue1.x

所以在 Vue2 中通过引入了 VNode 和 diff 算法来解决该问题。通过降低 Watcher 的粒度,一个组件对应一个 Watcher(渲染 Watcher),这样就不会出现大型页面 Watcher 太多导致性能下降的问题。

在 Vue1 中,Watcher 和 页面中的响应式数据一一对应,当响应式数据发生改变,Dep 通知 Watcher 完成对应的 DOM 更新。但是在 Vue2 中一个组件对应一个 Watcher,当响应式数据发生改变时,Watcher 并不知道这个响应式数据在组件中的什么位置,那又该如何完成更新呢?

阅读过之前的 源码系列,大家肯定都知道,Vue2 引入了 VNode 和 diff 算法,将组件 编译 成 VNode,每次响应式数据发生变化时,会生成新的 VNode,通过 diff 算法对比新旧 VNode,找出其中发生改变的地方,然后执行对应的 DOM 操作完成更新。

所以,到这里大家也能明白,Vue1 和 Vue2 在核心的数据响应式部分其实没什么变化,主要的变动在编译器部分。

目标

完成 Vue2 编译器的一个简版实现,从字符串模版解析开始,到最终得到 render 函数。

编译器

在手写 Vue1 时,编译器时通过 DOM API 来遍历模版的 DOM 结构来完成的,在 Vue2 中不再使用这种方式,而是和官方一样,直接编译组件的模版字符串,生成 AST,然后从 AST 生成渲染函数。

首先将 Vue1 的 compiler 目录备份,然后新建一个 compiler 目录,作为 Vue2 的编译器目录

mv compiler compiler-vue1 && mkdir compiler

mount

/src/compiler/index.js

/**
 * 编译器
 */
export default function mount(vm) {
  if (!vm.$options.render) { // 没有提供 render 选项,则编译生成 render 函数
    // 获取模版
    let template = ''

    if (vm.$options.template) {
      // 模版存在
      template = vm.$options.template
    } else if (vm.$options.el) {
      // 存在挂载点
      template = document.querySelector(vm.$options.el).outerHTML
      // 在实例上记录挂载点,this._update 中会用到
      vm.$el = document.querySelector(vm.$options.el)
    }

    // 生成渲染函数
    const render = compileToFunction(template)
    // 将渲染函数挂载到 $options 上
    vm.$options.render = render
  }
}

compileToFunction

/src/compiler/compileToFunction.js

/**
 * 解析模版字符串,得到 AST 语法树
 * 将 AST 语法树生成渲染函数
 * @param { String } template 模版字符串
 * @returns 渲染函数
 */
export default function compileToFunction(template) {
  // 解析模版,生成 ast
  const ast = parse(template)
  // 将 ast 生成渲染函数
  const render = generate(ast)
  return render
}

parse

/src/compiler/parse.js

/**
 * 解析模版字符串,生成 AST 语法树
 * @param {*} template 模版字符串
 * @returns {AST} root ast 语法树
 */
export default function parse(template) {
  // 存放所有的未配对的开始标签的 AST 对象
  const stack = []
  // 最终的 AST 语法树
  let root = null

  let html = template
  while (html.trim()) {
    // 过滤注释标签
    if (html.indexOf('<!--') === 0) {
      // 说明开始位置是一个注释标签,忽略掉
      html = html.slice(html.indexOf('-->') + 3)
      continue
    }
    // 匹配开始标签
    const startIdx = html.indexOf('<')
    if (startIdx === 0) {
      if (html.indexOf('</') === 0) {
        // 说明是闭合标签
        parseEnd()
      } else {
        // 处理开始标签
        parseStartTag()
      }
    } else if (startIdx > 0) {
      // 说明在开始标签之间有一段文本内容,在 html 中找到下一个标签的开始位置
      const nextStartIdx = html.indexOf('<')
      // 如果栈为空,则说明这段文本不属于任何一个元素,直接丢掉,不做处理
      if (stack.length) {
        // 走到这里说说明栈不为空,则处理这段文本,并将其放到栈顶元素的肚子里
        processChars(html.slice(0, nextStartIdx))
      }
      html = html.slice(nextStartIdx)
    } else {
      // 说明没有匹配到开始标签,整个 html 就是一段文本
    }
  }
  return root
  
  // parseStartTag 函数的声明
  // ...
  // processElement 函数的声明
}

// processVModel 函数的声明
// ...
// processVOn 函数的声明

parseStartTag

/src/compiler/parse.js

/**
 * 解析开始标签
 * 比如: <div id="app">...</div>
 */
function parseStartTag() {
  // 先找到开始标签的结束位置 >
  const end = html.indexOf('>')
  // 解析开始标签里的内容 <内容>,标签名 + 属性,比如: div id="app"
  const content = html.slice(1, end)
  // 截断 html,将上面解析的内容从 html 字符串中删除
  html = html.slice(end + 1)
  // 找到 第一个空格位置
  const firstSpaceIdx = content.indexOf(' ')
  // 标签名和属性字符串
  let tagName = '', attrsStr = ''
  if (firstSpaceIdx === -1) {
    // 没有空格,则认为 content 就是标签名,比如 <h3></h3> 这种情况,content = h3
    tagName = content
    // 没有属性
    attrsStr = ''
  } else {
    tagName = content.slice(0, firstSpaceIdx)
    // content 的剩下的内容就都是属性了,比如 id="app" xx=xx
    attrsStr = content.slice(firstSpaceIdx + 1)
  }
  // 得到属性数组,[id="app", xx=xx]
  const attrs = attrsStr ? attrsStr.split(' ') : []
  // 进一步解析属性数组,得到一个 Map 对象
  const attrMap = parseAttrs(attrs)
  // 生成 AST 对象
  const elementAst = generateAST(tagName, attrMap)
  // 如果根节点不存在,说明当前节点为整个模版的第一个节点
  if (!root) {
    root = elementAst
  }
  // 将 ast 对象 push 到栈中,当遇到结束标签的时候就将栈顶的 ast 对象 pop 出来,它两就是一对儿
  stack.push(elementAst)

  // 自闭合标签,则直接调用 end 方法,进入闭合标签的处理截断,就不入栈了
  if (isUnaryTag(tagName)) {
    processElement()
  }
}

parseEnd

/src/compiler/parse.js

/**
 * 处理结束标签,比如: <div id="app">...</div>
 */
function parseEnd() {
  // 将结束标签从 html 字符串中截掉
  html = html.slice(html.indexOf('>') + 1)
  // 处理栈顶元素
  processElement()
}

parseAttrs

/src/compiler/parse.js

/**
 * 解析属性数组,得到一个属性 和 值组成的 Map 对象
 * @param {*} attrs 属性数组,[id="app", xx="xx"]
 */
function parseAttrs(attrs) {
  const attrMap = {}
  for (let i = 0, len = attrs.length; i < len; i++) {
    const attr = attrs[i]
    const [attrName, attrValue] = attr.split('=')
    attrMap[attrName] = attrValue.replace(/"/g, '')
  }
  return attrMap
}

generateAST

/src/compiler/parse.js

/**
 * 生成 AST 对象
 * @param {*} tagName 标签名
 * @param {*} attrMap 标签组成的属性 map 对象
 */
function generateAST(tagName, attrMap) {
  return {
    // 元素节点
    type: 1,
    // 标签
    tag: tagName,
    // 原始属性 map 对象,后续还需要进一步处理
    rawAttr: attrMap,
    // 子节点
    children: [],
  }
}

processChars

/src/compiler/parse.js

/**
 * 处理文本
 * @param {string} text 
 */
function processChars(text) {
  // 去除空字符或者换行符的情况
  if (!text.trim()) return

  // 构造文本节点的 AST 对象
  const textAst = {
    type: 3,
    text,
  }
  if (text.match(/{{(.*)}}/)) {
    // 说明是表达式
    textAst.expression = RegExp.$1.trim()
  }
  // 将 ast 放到栈顶元素的肚子里
  stack[stack.length - 1].children.push(textAst)
}

processElement

/src/compiler/parse.js

/**
 * 处理元素的闭合标签时会调用该方法
 * 进一步处理元素上的各个属性,将处理结果放到 attr 属性上
 */
function processElement() {
  // 弹出栈顶元素,进一步处理该元素
  const curEle = stack.pop()
  const stackLen = stack.length
  // 进一步处理 AST 对象中的 rawAttr 对象 { attrName: attrValue, ... }
  const { tag, rawAttr } = curEle
  // 处理结果都放到 attr 对象上,并删掉 rawAttr 对象中相应的属性
  curEle.attr = {}
  // 属性对象的 key 组成的数组
  const propertyArr = Object.keys(rawAttr)

  if (propertyArr.includes('v-model')) {
    // 处理 v-model 指令
    processVModel(curEle)
  } else if (propertyArr.find(item => item.match(/^v-bind:(.*)/))) {
    // 处理 v-bind 指令,比如 <span v-bind:test="xx" />
    processVBind(curEle, RegExp.$1, rawAttr[`v-bind:${RegExp.$1}`])
  } else if (propertyArr.find(item => item.match(/^v-on:(.*)/))) {
    // 处理 v-on 指令,比如 <button v-on:click="add"> add </button>
    processVOn(curEle, RegExp.$1, rawAttr[`v-on:${RegExp.$1}`])
  }

  // 节点处理完以后让其和父节点产生关系
  if (stackLen) {
    stack[stackLen - 1].children.push(curEle)
    curEle.parent = stack[stackLen - 1]
  }
}

processVModel

/src/compiler/parse.js

/**
 * 处理 v-model 指令,将处理结果直接放到 curEle 对象身上
 * @param {*} curEle 
 */
function processVModel(curEle) {
  const { tag, rawAttr, attr } = curEle
  const { type, 'v-model': vModelVal } = rawAttr

  if (tag === 'input') {
    if (/text/.test(type)) {
      // <input type="text" v-model="inputVal" />
      attr.vModel = { tag, type: 'text', value: vModelVal }
    } else if (/checkbox/.test(type)) {
      // <input type="checkbox" v-model="isChecked" />
      attr.vModel = { tag, type: 'checkbox', value: vModelVal }
    }
  } else if (tag === 'textarea') {
    // <textarea v-model="test" />
    attr.vModel = { tag, value: vModelVal }
  } else if (tag === 'select') {
    // <select v-model="selectedValue">...</select>
    attr.vModel = { tag, value: vModelVal }
  }
}

processVBind

/src/compiler/parse.js

/**
 * 处理 v-bind 指令
 * @param {*} curEle 当前正在处理的 AST 对象
 * @param {*} bindKey v-bind:key 中的 key
 * @param {*} bindVal v-bind:key = val 中的 val
 */
function processVBind(curEle, bindKey, bindVal) {
  curEle.attr.vBind = { [bindKey]: bindVal }
}

processVOn

/src/compiler/parse.js

/**
 * 处理 v-on 指令
 * @param {*} curEle 当前被处理的 AST 对象
 * @param {*} vOnKey v-on:key 中的 key
 * @param {*} vOnVal v-on:key="val" 中的 val
 */
function processVOn(curEle, vOnKey, vOnVal) {
  curEle.attr.vOn = { [vOnKey]: vOnVal }
}

isUnaryTag

/src/utils.js

/**
 * 是否为自闭合标签,内置一些自闭合标签,为了处理简单
 */
export function isUnaryTag(tagName) {
  const unaryTag = ['input']
  return unaryTag.includes(tagName)
}

generate

/src/compiler/generate.js

/**
 * 从 ast 生成渲染函数
 * @param {*} ast ast 语法树
 * @returns 渲染函数
 */
export default function generate(ast) {
  // 渲染函数字符串形式
  const renderStr = genElement(ast)
  // 通过 new Function 将字符串形式的函数变成可执行函数,并用 with 为渲染函数扩展作用域链
  return new Function(`with(this) { return ${renderStr} }`)
}

genElement

/src/compiler/generate.js

/**
 * 解析 ast 生成 渲染函数
 * @param {*} ast 语法树 
 * @returns {string} 渲染函数的字符串形式
 */
function genElement(ast) {
  const { tag, rawAttr, attr } = ast

  // 生成属性 Map 对象,静态属性 + 动态属性
  const attrs = { ...rawAttr, ...attr }

  // 处理子节点,得到一个所有子节点渲染函数组成的数组
  const children = genChildren(ast)

  // 生成 VNode 的可执行方法
  return `_c('${tag}', ${JSON.stringify(attrs)}, [${children}])`
}

genChildren

/src/compiler/generate.js

/**
 * 处理 ast 节点的子节点,将子节点变成渲染函数
 * @param {*} ast 节点的 ast 对象 
 * @returns [childNodeRender1, ....]
 */
function genChildren(ast) {
  const ret = [], { children } = ast
  // 遍历所有的子节点
  for (let i = 0, len = children.length; i < len; i++) {
    const child = children[i]
    if (child.type === 3) {
      // 文本节点
      ret.push(`_v(${JSON.stringify(child)})`)
    } else if (child.type === 1) {
      // 元素节点
      ret.push(genElement(child))
    }
  }
  return ret
}

结果

mount 方法中加一句 console.log(vm.$options.render),打开控制台,刷新页面,看到如下内容,说明编译器就完成了

image.png

接下来就会进入正式的挂载阶段,完成页面的初始渲染。

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容