【手写 Vue2.x 源码】第十六篇 - 生成 render 函数 - 代码拼接

591 阅读6分钟

一,前言

上篇,生成 ast 语法树 - 构造树形结构部分

  • 基于 html 特点,使用栈型数据结构记录父子关系;
  • 分析开始标签,结束标签及文本的ast构建逻辑和代码实现;
  • 重构 html 解析与 ast 语法树构建部分代码;
  • 对 ast 语法树构建的过程分析;

本篇,使用 ast 语法树生成 render 函数 - 代码拼接部分


二,前文回顾

1,流程梳理

第十二篇提到过,compileToFunction方法是 Vue 编译的入口;

compileToFunction方法中,主要做了两件事:

  1. parserHTML:将模板内容编译为 ast 语法树;
  2. generate:再根据 ast 语法树生成为 render 函数;

Vue 编译阶段的最终产物就是 render 函数:

//  src/compiler/index.js

export function compileToFunction(template) {
  // 1,将模板编译称为 AST 语法树
  let ast = parserHTML(template);
  // 2,使用 AST 生成 render 函数
  let code = generate(ast);
}

之前几篇中,通过parserHTML方法完成了html模板的解析并构建出ast语法树;

接下来,继续实现generate方法:使用ast语法树生成render函数;

2,模板编译示例

之前提到过 render 函数,模板编译示例:

image.png

由左侧的html模板 -> 生成为右侧的render函数:

  • _c 等价于 createElement 创建一个元素;
  • _v 等价于 _vnode
  • _s 等价于 JSON.stringify() 转换为字符串;

二,生成 render 函数 - 代码拼接

1,render 函数的实现方式

generate方法:根据 ast 语法树生成为 render 函数;

即当外部调用generate方法时,传入ast语法树,返回render函数;

根据“模板编译示例”中render函数的结构,通过字符串拼接的方式生成render函数代码;

2,render 函数之代码拼接:generate(ast)

仿照“模板编译示例”中render函数的语法和结构,根据ast语法树中的元素节点信息,采用字符串拼接的方式构建出render函数:

// src/compiler/index.js#generate

function generate(ast) {
 // 字符串拼接 render 函数
 let code = `_c('${ast.tag}',${
  // 暂不处理属性,后面单独处理
  ast.attrs.length? JSON.stringify({}):'undefined'	
 }${
  ast.children?`,[]`:''  // 暂不处理儿子,后面单独处理
 })`

 return code;
}

// 输出结果:_c('div',{},[]}

以上操作拼接出了render函数的大致结构,接下来继续处理属性和儿子;

3,处理属性:genProps(ast.attrs)

创建用于处理属性的genProps方法:通过拼接字符串方式格式化属性信息;(将数组格式化为字符串)

// src/compiler/index.js

// 将 attrs 数组格式化为:{key=val,key=val}
function genProps(attrs) {
  let str = '';
  for(let i = 0; i< attrs.length; i++){
    let attr = attrs[i];
    // 使用 JSON.stringify 将 value 转为 string 类型
    // 注意:每个属性值的最后都会添加一个逗号
    str += `${attr.name}:${JSON.stringify(attr.value)},`
  }
  return `{${str.slice(0, -1)}}`;// 去掉最后一位多余的逗号,在外层以{}包裹
 }

// 在 generate 中调用 genProps 处理属性
function generate(ast) {
 let code = `_c('${ast.tag}',${
  ast.attrs.length? genProps(ast.attrs):'undefined'
 }${
  ast.children?`,[]`:''
 })`
 return code;
}

export function compileToFunction(template) {
  let ast = parserHTML(template);
  let code = generate(ast);
  console.log(code)
}

// _c('div',{id:"app",a:"1",b:"2"},[]}

4,处理属性中的样式

style属性中会存在样式信息,也需要在处理属性时一并处理:

<div id="app" a='1' b=2 style="color: red;background: blue;">
  <p>{{message}} 
    <span>Hello Vue</span>
  </p>
</div>

继续,还需要将样式数组也格式化为对象形式:

// src/compiler/index.js#genProps

// 将 attrs 数组格式化为:{key=val,key=val}
function genProps(attrs) {
  let str = '';
  for(let i = 0; i< attrs.length; i++){
    let attr = attrs[i];
    
    // ****** 将样式数组处理为对象 {name:id, value:'app'} ****** //
    // <div id="app" style="color: red;background: blue;"></div>
    // 使用 replace 进行正则匹配,对样式进行 key,value 替换
    if(attr.name == "style"){
      let styles = {};
      // 正则解析: 中间以分号隔开,两测不能有冒号(分割)和分号(结尾)
      attr.value.replace(/([^;:]+):([^;:]+)/g, function () {
        // key:arguments[1]、value:arguments[2]
        styles[arguments[1]] = arguments[2]
      }) 
      // 替换为处理后的样式对象
      attr.value = styles;
    }
    str += `${attr.name}:${JSON.stringify(attr.value)},`
  }
  
  return `{${str.slice(0, -1)}}`;
 }


// 打印输出:
// _c('div',
//    {id:"app",a:"1",b:"2",style:{"color":" red","background":" blue"}},
//    []}

测试输出:

image.png

注意:这里并没有对onclick等事件进行处理,仅以属性作为实现的示例;

5,处理儿子(递归):genChildren(el)

处理好属性之后,继续处理儿子,示例如下:

<div id="app" a='1' b=2 style="color: red;background: blue;">
  <p>{{message}} 
    <span>Hello Vue1</span>
    <span>Hello Vue2</span>
    <span>Hello Vue3</span>
  </p>
</div>
  • 儿子可能是标签,也可能是文本;
  • 当儿子为标签时,标签中可能还存在多个儿子,需要对当前标签做递归处理;
// src/compiler/index.js

// 输出结果:_c(div,{},c1,c2,c3...)
function generate(ast) {
  // 处理儿子
  let children = genChildren(ast);
  
  let code = `_c('${ast.tag}',${
    ast.attrs.length? genProps(ast.attrs):'undefined'
  }${
    children?`,${children}`:''
  })`
  
  return code;
}

function genChildren(el) {
  console.log("===== genChildren =====")
  let children = el.children;
  if(children){
    console.log("存在 children, 开始遍历处理子节点...", children)
    let result = children.map(item => gen(item)).join(',');
    console.log("子节点处理完成,result = " + JSON.stringify(result))
    return result
  }
  console.log("不存在 children, 直接返回 false")
  return false;
}

function gen(el) {
  console.log("===== gen ===== el = ",el)
  if(el.type == 1){ 
    console.log("元素标签 tag = "+el.tag+",generate继续递归处理")
    // 标签类型:标签中可能还存在多个儿子,需要继续递归处理当前元素(标签)
    return generate(el);
  }else{
    console.log("文本类型,text = " + el.text)
    // 文本类型:直接返回
    return el.text ;    
  }
}

// _c('div',{id:"app",a:"1",b:"2",style:{"color":" red","background":" blue"}},
//    _c('p',undefined,_v(_s(message)),
//       _c('span',undefined,_v('HelloVue1')),
//       _c('span',undefined,_v('HelloVue2')),
//       _c('span',undefined,_v('HelloVue3'))
//    )
// )

image.png

根据 render 函数特征,对文本类型包装_v、对变量类型包装_s;

6,为文本类型包装 _v

function gen(el) {
  console.log("===== gen ===== el = ",el)
  if(el.type == 1){// 
    console.log("元素标签 tag = "+el.tag+",generate继续递归处理")
    return generate(el);// 如果是元素就递归的生成
  }else{// 文本类型
    let text = el.text
    console.log("文本类型,text = " + text)
    return `_v('${text}')`  // 包装 _v
  }
}

type == 2的文本类型中,还有可能存在插值表达式的情况,如:{{ msg }}

而插值表达式需要包装_s,需通过正则匹配的方式将其过滤掉;

7,为变量包装 _s

对照render函数的生成结果:

image.png

处理逻辑如下:

  • 文本 -> 包装 _v
  • 变量 -> 包装 _s
  • 字符串 -> 包装 ""

模板中的插值表达式{{ name }}

  • name可能是一个对象,因此需要使用_s(同JSON.stringify)转换成为字符串;

通过正则defaultTagRE 检查 text文本中是否包含{{}}

  • 若不包含,直接返回 _v('${text}')
  • 若包含,说明是表达式,需要对表达式 和 普通值执行拼接操作:将表达式和普通值按顺序放入tokens数组中,执行拼接操作后再返回,即:_v(${tokens.join('+')})
['aaa',_s(name),'bbb'].join('+') ==> _v('aaa' + s_(name) + 'bbb')

8,完整实现

// src/compiler/index.js#gen
// 匹配 {{}} 表达式
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g

function gen(el) {
  console.log("===== gen ===== el = ",el)
  // ************ 标签类型 ************ //
  if(el.type == 1){
    console.log("元素标签 tag = "+el.tag+",generate继续递归处理")
    // 标签元素需要递归执行生成逻辑
    return generate(el);
  // ************ 文本类型 ************ //
  }else{
    let text = el.text
    console.log("文本类型,text = " + text)
    
    // ************ 普通文本 ************ //
    if(!defaultTagRE.test(text)){
      // 普通文本,包装_v
      return `_v('${text}')`  
    // ************ {{}} 表达式文本 ************ //
    }else{
      // 存在 {{}} 表达式,对 表达式 和 普通值 执行拼接操作 
      // 目标:['aaa',_s(name),'bbb'].join('+') ==> _v('aaa' + s_(name) + 'bbb')
      let lastIndex = defaultTagRE.lastIndex = 0; // 重置索引标记
      let tokens = []; // <div>aaa {{name}} bbb</div>
      let match
      
      // ************ 捕获 {{}} 表达式并处理 ************ //
      // exec 循环捕获结果
      while(match = defaultTagRE.exec(text)){
        console.log("匹配内容" + text)
        
        // match.index:获取当前捕获到的位置
        let index = match.index;
        console.log("当前的 lastIndex = " + lastIndex)
        console.log("匹配的 match.index = " + index)
        
        // index > lastIndex:说明匹配到了内容,将前一段'<div>aaa '中的 aaa 放入 tokens 数组中
        if(index > lastIndex){
          let preText = text.slice(lastIndex, index)
          console.log("匹配到表达式-找到表达式开始前的部分:" + preText)
          tokens.push(JSON.stringify(preText))// 利用 JSON.stringify 添加双引号
        }
        console.log("匹配到表达式:" + match[1].trim())
        // 继续放入 match 到的表达式,如:{{ name  }}
        // match[1]:表示花括号中间的内容 name,需要处理表达式部分可能存在的换行或回车
        tokens.push(`_s(${match[1].trim()})`)
        
        // 更新 lastIndex 长度到'<div>aaa {{name}}',用于下一次 while 对比匹配结果
        lastIndex = index + match[0].length;  
      }

      // ************ 处理剩余部分 ************ //
      // while 循环结束后,可能还剩余了一段,比如:’ bbb</div>’
      // lastIndex < text.length:说明还有剩余内容需要处理,将 bbb 放入 tokens 数组中
      if(lastIndex < text.length){
        let lastText = text.slice(lastIndex);
        console.log("表达式处理完成后,还有内容需要继续处理:"+lastText)
        tokens.push(JSON.stringify(lastText))// 从 lastIndex 到最后
      }
      
      return `_v(${tokens.join('+')})`
    }
  }
}

基于以上完整实现,再做一次总结性说明:

todo:后续优化描述,添加表达式部分的截取分析和 log 跟踪 todo:这里的总结性说明与上边的部分内容有重复,有些啰嗦了,可以合并到一起;

对文本的处理逻辑:

  1. 如果没有特殊的表达式,直接返回;
  2. 如果有表达式,需进行匹配和截取处理;

使用正则进行捕获处理,可能存在较复杂的情况,如:

<div>aaa {{name}} bbb</div>
<!-- 或 --> 
<div>aaa {{name}} bbb {{age}} ccc</div>
  1. 使用正则defaultTagRE捕获表达式,将表达式前面的一段<div>aaa 中的aaa放入tokens数组;

    备注:本次捕获完成后,得到偏移量在表达式后,待表达式处理完成后统一调整即可;

  2. 将捕获到的表达式名称name放入tokens数组中并修改匹配偏移量;同理,继续处理其余表达式;

    备注:每次捕获成功之后,重复1,2两个步骤;

  3. 当表达式全部捕获完成后,若文本长度仍大于当前匹配偏移量,说明还有最后一段没有处理, 将 bbb</div>中的bbb也放入tokens数组中;

  4. 全部放入tokens数组后,拼接返回 _v(${tokens.join('+')})

image.png

最终,就完成了code字符串的拼接,即render函数内部return的代码;

image.png

至此,距离得到最终的render函数,还差外边的两层:

  • 向外一层with代码块的包裹;
  • 再向外一层Function函数的包裹;

三,结尾

本篇,主要介绍了生成 render 函数 - 代码拼接

  • render 函数的分析和实现方案;
  • 拼接 render 函数结构:generate(ast);
  • 处理属性及属性值中的样式:genProps(ast.attrs);
  • 递归处理儿子:genChildren(el);
  • 对文本和变量进行包装处理,详细分析文本处理流程;

至此,render 函数中 with 内部的代码拼接已经完成;

下一篇,生成 render 函数 - 函数生成


维护日志:

  • 20230126:调整了目录结构,添加了部分代码注释,补充了对关键逻辑的描述,更新专栏摘要;
  • 20230127:添加了部分代码注释和适当的代码换行,添加 todo;