前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >震惊!这家伙居然用中文写 vue 组件

震惊!这家伙居然用中文写 vue 组件

作者头像
码农小余
发布2022-06-16 16:44:07
4780
发布2022-06-16 16:44:07
举报
文章被收录于专栏:码农小余

大家好,我是码农小余。作为手摸手实现编译器的终(下)篇,调皮地改了一个标题。回顾前两篇内容:

在上篇文末有讲到,编译成 AST 的之后需要 transform,最终 generate 代码。在 vue模板编译中有 optimize 标记静态节点的优化和 generate 生成代码;在 babel@babel/traverse 做节点遍历,用 @babel/generator 生成代码。今天小余就结合 vue 框架将生成的 AST 生成浏览器真实的 DOM ,以此来实践 AST generate code 的过程。

目标

要结合 vue 去生成有以下四种方式:

  1. 通过 AST 生成 render 函数字符串(本文细讲这种方式,其他感兴趣的童鞋可以尝试练手);
  2. 通过转换 AST,生成 vueVNode 的结构;
  3. 通过 AST 生成 SFC 中的 template
  4. 通过 AST 去封装一套 patch 逻辑,通过 DOM-API 去处理;

简析

没有阅读过系列中篇的童鞋可能不太清楚状况,这里简单提一下。中篇我们有以下中文的模板:

代码语言:javascript
复制
<下拉框 值="番茄">
  <选项 值="番茄">番茄</选项>
  <选项 值="香蕉">香蕉</选项>
</下拉框>

通过 zh-template-compiler 生成的 AST 结构如下:

代码语言:javascript
复制
{
  "type": 1,
  "tag": "下拉框",
  "attrs": [
    {
      "isBind": false,
      "name": "值",
      "value": "番茄"
    }
  ],
  "children": [
    {
      "type": 1,
      "tag": "选项",
      "attrs": [
        {
          "isBind": false,
          "name": "值",
          "value": "番茄"
        }
      ],
      "children": [
        "番茄"
      ]
    },
    {
      "type": 1,
      "tag": "选项",
      "attrs": [
        {
          "isBind": false,
          "name": "值",
          "value": "香蕉"
        }
      ],
      "children": [
        "香蕉"
      ]
    }
  ]
}

将上述结构要生成在浏览器中能够显示的 DOM,我们需要的 html 代码就如下面这个样子(这里可以结合任意 UI 组件库去发挥,为了突出本文的重点,不把场景整复杂了):

代码语言:javascript
复制
<select value="番茄">
  <option value="番茄">番茄</option>
  <option value="香蕉">香蕉</option>
</select>

上述 DOM 转换成 vuerender 函数的写是:

代码语言:javascript
复制
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>zh-template-compiler</title>
</head>
<body>
  <div id="app"></div>
  <script src="../node_modules/vue/dist/vue.global.js"></script>
  <script>
    const { createApp, h, ref } = Vue

    const app = createApp({     
      render (_ctx) {
        return h('select', {
          value: '番茄'
        }, [
          h('option', { value: '番茄' }, '番茄'),
          h('option', { value: '香蕉' }, '香蕉')
        ])
      }
    })

    app.mount('#app')
  </script>
</body>
</html>

既然这样就能显示,我们的任务就更加详细和明确了,将 AST 转换成 render 函数:

  1. 中文和标签的映射,“下拉框”转换成 select,“选项”转换成 option
  2. attrs 转换成标签上的属性,name 等于“值”的 attr 全部生成 value = "xx" 的形式;
  3. children 递归执行上述一、二步骤;

我们将 AST 转换成代码片段至此就分析完成了,接下来就开始撸代码。

测试

之前这一步骤都是直接贴测试代码了,那样可能对于问题的思考和测试代码的编写没有太大的指导意义。本文就由简入难一步一步地来写测试,使用的测试工具是 antfu 使用两周时间就冲上了 2021 测试框架排行榜第九名的 Vitest[1] ,非常非常好用,快入坑。

首先,写单测一定要从最小场景开始梳理,对于本文的 🌰而言,最小的 AST 即:

代码语言:javascript
复制
const ast: NODE = {
  type: 1,
  tag: "下拉框",
  attrs: [],
  children: []
}

上述 AST 对应的中文模板代码是 <下拉框></下拉框>,对应的 html 代码是 <select></select>,对应的 render 函数就很明了了:

代码语言:javascript
复制
render (_ctx) {
    return h('select', {})
}

第一个测试用例也就出来了:

代码语言:javascript
复制
describe("中文 ast 生成 render 函数", () => {
  test("单个不带属性节点", () => {
    const ast: NODE = {
      type: 1,
      tag: "下拉框",
      attrs: [],
      children: []
    }

    expect(generate(ast)).toBe(`render (_ctx) {
    return h('select', {})}`)
  })
})

然后第二个场景是组件带有属性的情况,<下拉框 值="番茄"></下拉框> 对应的 html 代码是 <select value="番茄"></select>,对应的 render 函数即:

代码语言:javascript
复制
render (_ctx) {
    return h('select', {value: '番茄'})
}

第二个测试用例就出来了

代码语言:javascript
复制
test('单个带属性的节点', () => {
  const ast: NODE = {
    type: 1,
    tag: '下拉框',
    attrs: [
      {
        isBind: false,
        name: '值',
        value: '番茄'
      }
    ],
    children: []
  }

  expect(generate(ast)).toBe(`render (_ctx) {
    return h('select', {"value":"番茄"})}`)
})

第三个用例自然就考虑 children 了,此时应该抛开第二个测试用例 attrs 的值,在写 children 的时候,因为 children 支持字符串和节点类型,所以按照由简入深原则,我们先考虑文本的场景 :

代码语言:javascript
复制
// 中文模板
<选项>番茄</选项>

// 对应的 html 代码
<option>番茄</option>

// 对应的 render 函数
render (_ctx) {
    return h('option', {}, '番茄')
}

// 生成的测试用例代码
test('带文本孩子的节点', () => {
  const ast: NODE = {
    type: 1,
    tag: '选项',
    attrs: [],
    children: ['番茄']
  }

  expect(generate(ast)).toBe(`render (_ctx) {
    return h('option', {}, '番茄')}`)
})

上述思考过程中有一个细节,为什么就不用 “下拉框”?突然转用“选项”了呢?如果考虑到这一点,就可能会因为好奇去查 MDN 文档[2],顺道补充了自己的基础知识,这难道不是单元测试的魅力吗?

写完 children 是文本的情况,接下来就写 children 是节点的情况:

代码语言:javascript
复制
// 中文模板
<下拉框>
  <选项></选项>  
</下拉框>

// 对应的 html 代码
<select>
  <option></option>
</select>

// 对应的 render 函数
render (_ctx) {
    return h('select', {}, [h('option', {})])
}

// 生成的测试用例代码
 test('只带节点孩子的节点', () => {
   const ast: NODE = {
     type: 1,
     tag: '下拉框',
     attrs: [],
     children: [
       {
         type: 1,
         tag: '选项',
         attrs: [],
         children: [
         ]
       }
     ]
   }

   expect(generate(ast)).toBe(`render (_ctx) {
    return h('select', {}, [h('option', {})])}`)
 })

到这里,所有单个属性的基本类型都考虑完了。接下来就是组合属性之间的测试代码,这部分就不一一列举了,排列组合,要将全部属性的搭配都考虑完全,直接给出代码:

代码语言:javascript
复制
describe("ast 生成器", () => {

  // 省略上述已经分析过的用例...

  test('带两种类型孩子的节点', () => {
    const ast: NODE = {
      type: 1,
      tag: '下拉框',
      attrs: [],
      children: [
        {
          type: 1,
          tag: '选项',
          attrs: [],
          children: [
            '番茄'
          ]
        }
      ]
    }

    expect(generate(ast)).toBe(`render (_ctx) {
      return h('select', {}, [h('option', {}, '番茄')])}`)
  })

  test('带标签孩子的节点', () => {
    const ast: NODE = {
      type: 1,
      tag: '下拉框',
      attrs: [],
      children: [
        {
          type: 1,
          tag: '选项',
          attrs: [
            {
              isBind: false,
              name: '值',
              value: '番茄'
            }
          ],
          children: [
            '番茄'
          ]
        }
      ]
    }

    expect(generate(ast)).toBe(`render (_ctx) {
      return h('select', {}, [h('option', {"value":"番茄"}, '番茄')])}`)
  })

  test('带2个标签孩子的节点', () => {
    const ast: NODE = {
      type: 1,
      tag: '下拉框',
      attrs: [],
      children: [
        {
          type: 1,
          tag: '选项',
          attrs: [],
          children: [
            '番茄'
          ]
        },
        {
          type: 1,
          tag: '选项',
          attrs: [],
          children: [
            '香蕉'
          ]
        }
      ]
    }

    expect(generate(ast)).toBe(`render (_ctx) {
      return h('select', {}, [h('option', {}, '番茄'), h('option', {}, '香蕉')])}`)
  })
});

写完了测试用例,此时运行测试:

代码语言:javascript
复制
vitest -c vite.config.ts -u

因为我们一行代码都没写,所以自然全红:

编码

有了单元测试,接下来就将我们的注意力全部集中到代码上,这个环节我们只需怎么把代码写好即可,不会出现一边想需求一边写代码,中间发现有不满足的需求还有各种补充逻辑的窘境。大部分时候,每个人写的第一手代码都很 beautiful,但因为后续补充需求场景和迭代,就成了“屎山”。

回到 🌰 中需求,深度遍历 AST,去生成代码即可,核心代码如下:

代码语言:javascript
复制
function generateItem (node: NODE) {
  const { attrs, tag, } = node

  // 根据中文 tag 获取具体的 html 标签
  const dom = getTag(tag)
  // 根据中文属性名获取具体的 dom 属性
  const props = generateAttrs(attrs)

  return `h('${dom}', ${JSON.stringify(props)}`
}

export function generate (ast: NODE): string {
  let code = `render (_ctx) {
    return `

  function dfs (node: NODE) {
    if (!node) {
      return;
    }

    let str = generateItem(node)
    let children = node.children
    let len = children.length

    // 文本的情况
    if (len === 1 && typeof children[0] === 'string') {
      str += `, '${children[0]}')`
    // 没有子节点
    } else if (len === 0) {
      str += ')'
    } else {
      // 子节点数组
      let childrenArr = []
      for (let item of children) {
        childrenArr.push(dfs(item as NODE))
      }

      str += `, [${childrenArr.join(', ')}])`
    }

    return str
  }

  code += `${dfs(ast)}}`
  return code
}

代码比较简单,感兴趣的可以前往 github[3] 查看整体代码,整个过程类似 vue 中的 VNode 通过 patch 渲染 DOM 过程。经测试,测试用例全部变成绿色:

compilergenerate 都全部没问题了,接下来就整一个 DEMO,将二者结合起来:

代码语言:javascript
复制
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  </style>
  <title>zh-template-compiler</title>
</head>

<body>
  <div id="app"></div>
  <script src="../packages/parser/dist/zh-template-compiler.global.js"></script>
  <script src="../packages/generate/dist/zh-template-generate.global.js"></script>
  <script src="../node_modules/vue/dist/vue.global.js"></script>
  <script>
    
    const template = `<下拉框 值="番茄">
      <选项 值="番茄">番茄</选项>
      <选项 值="香蕉">香蕉</选项>
    </下拉框>`;
    const ast = zhTemplateCompiler.parse(template)

    const { createApp, h, ref } = Vue

    const app = createApp({
      render (_ctx) {
        const fn = new Function(`return ${zhTemplateGen.generate(ast)}`)

        return fn()
      }
    })
    app.mount('#app')
  </script>
</body>

</html>

最后运行 html

总结

作为编译器系列的最后一篇文章,将中篇中文模板生成的 AST 通过遍历并生成最终 render 代码后,基本就走过了 parsetraversegenerate 三个步骤。除了使用简单有趣的例子辅助理解之外,文中还有大量的热点技术使用,比如 pnpmvitest;最后还有一些常用的开发技巧,比如 TDD 的详细步骤指引,使用 pnpm workspace 的组织方式等等。

参考资料

[1]

Vitest: https://vitest.dev/

[2]

MDN 文档: https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/select

[3]

github: https://github.com/Jouryjc/zh-template-compiler

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-01-19,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 码农小余 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 目标
  • 简析
  • 测试
  • 编码
  • 总结
  • 参考资料
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档