【系列 2】手写vue模板编译

模板编译流程是什么?

1. 获取 outerHTML

<div id="app">{{name}}</div>

2. 正则查找转义成 ast 语法树

ast = {
    "tag": "div",
    "attrs": [
        {
            "name": "id",
            "value": "app"
        }
    ],
    "children": [
        {
            "type": 3,
            "text": "{{name}}"
        }
    ],
    "parent": null,
    "type": 1
}

3. ast 语法树转换成 render 字符串函数

 const code = '_c("div", {id:"app"},_v(_s(name)))'

4. 执行 redner 函数 生成 vnode

const render = new Function(`with(this){return ${code}}`)
const vnode = render.call(vm) // 目的就是让 render 函数 this 指向 vue 实例, 因为 vue 实例有 _c, _v, _s 这些方法

// vnode 大体结构如下
vnode = {
    children: [{children: null, data: null, key: null, tag: null, text: "小米", vm: Vue}]
    data: {id: 'app'}
    key: undefined
    tag: "div"
    text: null
    vm: Vue {$options: {}, _data: {}, $el: undefined,}
}

其实 vue 模板编译就上面这几个步骤,拆分一下是不是很清晰,接下来我们一步一步看看是怎么实现的!

1. 获取 outerHTML

很简单 el 就是用户 new Vue({ el: '#app' }) 里面的 #app, outerHTML 就是获取节点的 html 代码

 const el = document.querySelector(el);
 const template = el.outerHTML // <div id="app">{{name}}</div>

2. 正则查找转义成 ast 语法树

复制一下代码放入浏览器执行你会发现 ast 就被解析出来了

const template = '<div id="app">{{name}}</div>'
const ast = parserHTML(template)

解析过程:

  1. 找到标签头 <div 准备对象 {tagName: ‘div’,attrs: []}, 删除html上匹配到的内容
  2. 找到标签属性 id="app" 添加属性,------------------- 删除html上匹配到的内容
  3. 找到标签头结尾 > 返回 ast 入栈 [ ast ],-------------- 删除html上匹配到的内容
  4. 找到标签内容 {{name}} ast 添加 children,----------- 删除html上匹配到的内容
  5. 找到标签尾 </div> 出栈 [ ],-------------------------- 删除html上匹配到的内容

在这里插入图片描述

看懂了吗?就是将匹配到的一点一点删掉,直到 html 没了

下面的正则理解不了, 可以复制到 正则解析网站 里看看

const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`; // 用来描述标签的
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 标签开头的正则 捕获的内容是标签名
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配标签结尾的  捕获的是结束标签的标签名
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性的  分组1 拿到的是属性名  , 分组3 ,4, 5 拿到的是key对应的值

const startTagClose = /^\s*(\/?)>/; // 匹配标签结束的 /> or >

function parserHTML(html) {
  function advance(n) {
    html = html.substring(n) // 每次根据传入的长度截取html
    console.log("html 剩下", html)
  }

  // 树的操作 ,需要根据开始标签和结束标签产生一个树
  let root
  // 如何创建树的父子关系
  let stack = []

  function createASTElement(tagName, attrs) {
    return {
      tag: tagName,
      attrs,
      children: [],
      parent: null,
      type: 1
    }
  }

  // 开始标签进栈 (先进后出原理)
  function start(tagName, attrs) {
    let element = createASTElement(tagName, attrs)
    if (root == null) {
      root = element
    }
    let parent = stack[stack.length - 1] // 取到栈中的最后一个
    if (parent) {
      element.parent = parent       // 让这个元素记住自己的父亲是谁
      parent.children.push(element) // 让父亲记住儿子是谁
    }
    stack.push(element) //入栈
  }

  // 结束标签出栈
  function end(tagName) {
    stack.pop() //出栈
  }

  // 处理标签内容
  function chars(text) {
    text = text.replace(/\s/g, '')
    if (text) {
      let parent = stack[stack.length - 1]
      parent.children.push({ // 增加一个子元素
        type: 3, // 类型 3 表示文本
        text
      })
    }
  }

  //  ast 描述的是语法本身 ,语法中没有的,不会被描述出来  虚拟dom 是描述真实dom的可以自己增添属性
  while (html) {
    // 1. 处理开始标签 (就是处理 <div id="app">{{name}}</div>  的 <div id="app"> 部分)
    let textEnd = html.indexOf('<')
    if (textEnd === 0) {
      const startTagMatch = parseStartTag(); // 解析开始标签  {tagName:'div',attrs:[{name:"id",value:"app"}]}
      if (startTagMatch) {
        start(startTagMatch.tagName, startTagMatch.attrs)
        continue
      }

      // 3. 处理结束标签 (就是处理 <div id="app">{{name}}</div>  的 </div> 部分)
      let matches
      if (matches = html.match(endTag)) {
        end(matches[1])
        advance(matches[0].length)
        continue
      }
    }

    // 2. 处理标签内容 (就是处理 <div id="app">{{name}}</div> 的 {{name}} 部分)
    let text
    if (textEnd >= 0) {
      text = html.substring(0, textEnd)
    }
    if (text) {
      advance(text.length) // html 删去 text, 处理一点删一点
      chars(text)
    }
  }

  function parseStartTag() {
    const matches = html.match(startTagOpen) // 获取标签头 <div id="app">{{name}}</div> 的 <div 部分
    if (matches) {
      const match = {
        tagName: matches[1],
        attrs: []
      }
      advance(matches[0].length) // 删除html前面匹配到的标签名字符串
      let end, attr
      while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
        // while循环取属性 直到取完
        match.attrs.push({ name: attr[1], value: attr[3] || attr[4] || attr[5] || true })
        advance(attr[0].length) // 取到一个属性删除一个
      }
      if (end) {
        advance(end[0].length)
        return match
      }
    }
  }

  return root
}

3. ast 语法树转换成 render 字符串函数

const ast = {"tag": "div","attrs": [{"name": "id","value": "app"}],"children": [{"type": 3,"text": "{{name}}"}],"parent": null,"type": 1}  // 别看了就是上面那个 ast 对象

let code = genCode(ast) // '_c("div", {id:"app"},_v(_s(name)))'

逻辑如下:(看不下去就别看了,放浏览器执行一下看结果吧)

const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g // 匹配双花括号中间单的内容

function genCode(ast) {
  let code
  code = `_c("${ast.tag}", ${
    ast.attrs.length ? genProps(ast.attrs) : 'undefined'
  }${
    ast.children ? (',' + genChildren(ast)) : ''
  })` // 后面的参数都是 children

  return code
}

// 参数拼接成对象
function genProps(attrs) {
  let str = ''
  for (let i = 0; i < attrs.length; i++) {
    let attr = attrs[i]
    if (attr.name === 'style') {
      let obj = {}
      attr.value.split(';').reduce((memo, current) => {
        let [key, value] = current.split(':')
        memo[key] = value
        return memo
      }, obj)
      attr.value = obj // 这里是样式对象 例:{color:red,background:blue}
    }
    str += `${attr.name}:${JSON.stringify(attr.value)},`
  }
  return `{${str.slice(0,-1)}}` // 删除最后的 ,
}

function genChildren(ast) {
  const children = ast.children
  return children.map(child => gen(child)).join(',') // 孩子 , 拼接
}


function gen(node) {
  if (node.type === 1) {  // 是节点
    return genCode(node)
  } else {
    let text = node.text
    if (!defaultTagRE.test(text)) {
      return `_v(${JSON.stringify(text)})` // 不带表达式的
    } else {
      let tokens = []
      let match
      // exec 遇到全局匹配会有 lastIndex 问题 每次匹配前需要将lastIndex 置为 0
      let startIndex = defaultTagRE.lastIndex = 0
      while (match = defaultTagRE.exec(text)) {
        let endIndex = match.index // 匹配到索引
        if (endIndex > startIndex) {
          tokens.push(JSON.stringify(text.slice(startIndex, endIndex)))
        }
        tokens.push(`_s(${match[1].trim()})`)
        startIndex = endIndex + match[0].length
      }
      if (startIndex < text.length) { // 最后的尾巴放进去
        tokens.push(JSON.stringify(text.slice(startIndex)))
      }
      return `_v(${tokens.join('+')})` // 最后将动态数据 和非动态的拼接在一起
    }
  }
}

4. 执行 redner 函数 生成 vnode

new Function('字符串') 可以让字符串变成函数
with(this){内部的代码} 可以让内部的代码变量从 this 里获取

function Vue() {}
const code = '_c("div", {id:"app"},_v(_s(name)))'
lifeCycleMixin(Vue)  // 给 Vue 实例添加 _c, _v, _s 等方法

const vm = new Vue()
vm.$options = {
   render: new Function(`with(this){return ${code}}`)
}
mountComponent(vm)  // 生成 vnode 并且交给 _update 方法将 vnode 渲染成真实 dom

今天就介绍到生成 vnode 了,后续的 _update 方法里面包含了 diff 算法,这不是本系列的重点,放后面讲吧!

function lifeCycleMixin(Vue) {
  Vue.prototype._c = function() { // 生成 vnode
    return createElement(this, ...arguments)
  }
  Vue.prototype._v = function() {
    return createTextNode(this, ...arguments)
  }
  Vue.prototype._s = function(value) { // 将数据转化成字符串 因为使用的变量对应的结果可能是一个对象
    if(typeof value === 'object' && value !== null){
      return JSON.stringify(value)
    }
    return value
  }
  Vue.prototype._render = function() {
    const vm = this;
    const render = vm.$options.render;
    let vnode = render.call(vm); // _c( _s _v)  with(this)
    console.log("vnode =", vnode)
    return vnode;
  }
  Vue.prototype._update = function(vnode) { // 将虚拟节点变成真实节点
    // 将 vnode 渲染el元素中
    // const vm = this;
    // vm.$el = patch(vm.$el,vnode); // 可以初始化渲染, 后续更新也走这个patch方法
  }
}

function createElement(vm, tag, data = {}, ...children) {
    return vnode(vm,tag,data,children,data.key,null)
}

function createTextNode(vm,text) {
    return vnode(vm,null,null,null,null,text)
}

function vnode(vm,tag,data,children,key,text){
    return {
        vm,
        tag,
        data,
        children,
        key,
        text
        // ...
    }
}


function mountComponent(vm) {
  // 实现页面的挂载流程
  const updateComponent = () => {
    // 需要调用生成的render函数 获取到虚拟节点  -> 生成真实的dom
    vm._update(vm._render());
  }
  updateComponent(); // 如果稍后数据变化 也调用这个函数重新执行 
  // 后续:观察者模式 + 依赖收集 + diff算法
}

如果你想运行以上代码,ctrl + shift + n 进入无痕模式运行

在这里插入图片描述

看到这为什么 vue 不直接把 template 一步到位转成 vnode,而是 template 转成 render 再转成 vnode?
知道就写评论里,不知道就算了我不会告诉你的?

手写 vue 代码仓库链接
GitHubhttps://github.com/shunyue1320/vue-resolve/tree/vue-02
Giteehttps://gitee.com/shunyue/vue-resolve/tree/vue-02/
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值