【Svelte从入门到精通】实现篇——遍历

127 阅读1分钟

解析子节点

在前面的章节中,我们反复提到没有支持标签内子元素的展示,而导致我们在演示时只能干写标签而无内容,在本章中我们将完善这一功能。

首先我们要改造的是parseFragments:

function parseFragments(condition) {
  const fragments = [];
  while (condition()) {
    const fragment = parseFragment();
    if (fragment) {
      fragments.push(fragment);
    }
  }
  return fragments;
}

将其改为接收一个参数condition,condition是一个方法,执行后得到是否符合条件的布尔值。修改这一步的原因是因为在解析子元素时的while判断条件和初始解析时的判断条件不一样。

之后我们修改最初调用parseFragments时的代码,将原来while的判断改成一个方法参数传入。

ast.html = parseFragments(() => i < content.length);

修改parseElement的内容

function parseElement() {
  skipWhitespace();
  if (match('<')) {
    eat('<');
    const tagName = readWhileMatching(/[a-z]/);
    const attributes = parseAttributeList();
    eat('>');
    const endTag = `</${tagName}>`;
    const element = {
      type: 'Element',
      name: tagName,
      attributes,
-     children: []
+     children: parseFragments(() => !match(endTag)),
      };
    eat(endTag);
    skipWhitespace();
    return element;
  }
}

很明显,从一个标签开始到标签结束的这部分内容,我们把它看成是子标签的内容。

完善generate内traverse对Element的转换

function traverse(node, parent) {
  switch(node.type) {
    case 'Element': {
      ...

      node.children.forEach(child => {
        traverse(child, variableName);
      });

      ...
    }
  ...
}

往上一章中的App.svelte中添加内容:

<script>
  let count = 0;
  const updateCount = () => {
    count++;
    console.log('update count', count);
  }
</script>

文本内容1
<button on:click={updateCount}>标签内文本</button>
文本内容2

npm run compile执行看一下

prettier

现在我们已经有了基本的Svelte编译思路,然而我们不管是从我们执行npm run compile后的app.js还是从控制台中看,编译出来的代码的格式都非常不美观。

一种方式是我们每次在npm run compile后,手动地通过VSCode的格式化功能来格式化我们app.js的代码,那有没有更便捷的方式呢?答案便是我们可以在代码中引入Prettier

npm install prettier -D
import * as fs from "fs";
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
import * as acorn from "acorn";
import * as escodegen from "escodegen";
import * as prettier from "prettier";

修改我们的入口方法:

async function bootstrap() {
  try {
    const inputPath = resolve(modulePath, "./App.svelte");
    const outputPath = resolve(modulePath, "./app.js");
    const content = fs.readFileSync(inputPath, "utf-8");
    const compiledContent = compile(content);;
    const prettierContent = await prettier.format(compiledContent, {parser: 'babel'});
    fs.writeFileSync(outputPath, prettierContent, "utf-8");
  } catch (e) {
    console.error(e);
  }
}

重新执行npm run compile看下

之后我们就能清晰地看到经过编译后的js代码长什么样啦。

完整代码

import * as fs from "fs";
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
import * as acorn from "acorn";
import * as escodegen from "escodegen";
import * as prettier from "prettier";

const modulePath = dirname(fileURLToPath(import.meta.url));

async function bootstrap() {
  try {
    const inputPath = resolve(modulePath, "./App.svelte");
    const outputPath = resolve(modulePath, "./app.js");
    const content = fs.readFileSync(inputPath, "utf-8");
    const compiledContent = compile(content);
    const prettierContent = await prettier.format(compiledContent, {parser: 'babel'});
    fs.writeFileSync(outputPath, prettierContent, "utf-8");
  } catch (e) {
    console.error(e);
  }
}

function compile(content) {
  const ast = parse(content); // 解析svelte文件内容成ast
  return generate(ast);
}

function parse(content) {
  let i = 0;
  const ast = {};
  ast.html = parseFragments(() => i < content.length);
  
  return ast;

  function parseFragments(condition) {
    const fragments = [];
    while (condition()) {
      const fragment = parseFragment();
      if (fragment) {
        fragments.push(fragment);
      }
    }
    return fragments;
  }

  function parseFragment() {
    return parseScript() ?? parseElement() ?? parseText();
  }

  function parseScript() {
    skipWhitespace();
    if (match("<script>")) {
      eat("<script>");
      const startIndex = i;
      const endIndex = content.indexOf("</script>", i);
      const code = content.slice(startIndex, endIndex);
      ast.script = acorn.parse(code, { ecmaVersion: 2023 });
      i = endIndex;
      eat("</script>");
      skipWhitespace();
    }
  }

  function parseElement() {
    skipWhitespace();
    if (match('<')) {
      eat('<');
      const tagName = readWhileMatching(/[a-z]/);
      const attributes = parseAttributes();
      eat('>');
      const endTag = `</${tagName}>`;
      const element = {
        type: 'Element',
        name: tagName,
        attributes,
        children: parseFragments(() => !match(endTag)),
      };
      eat(endTag);
      skipWhitespace();
      return element;
    }
  }

  function parseAttributes() {
    skipWhitespace();
    const attributes = [];
    while(!match('>')) {
      attributes.push(parseAttribute());
      skipWhitespace();
    }
    return attributes;
  }

  function parseAttribute() {
    const name = readWhileMatching(/[^=]/);
    if (match('={')) {
      eat('={');
      const value = parseJavaScript();
      eat('}');
      return {
        type: 'Attribute',
        name,
        value,
      };
    }
  }

  function parseJavaScript() {
    const js = acorn.parseExpressionAt(content, i, { ecmaVersion: 2023 });
    i = js.end;
    return js;
  }

  function parseText() {
    const text = readWhileMatching(/[^<{]/);
    if (text.trim() !== '') {
      return {
        type: 'Text',
        value: text.trim(),
      }
    }
  }

  function match(str) {
    return content.slice(i, i + str.length) === str;
  }

  function eat(str) {
    if (match(str)) {
      i += str.length;
    } else {
      throw new Error(`Parse error: expecting "${str}"`);
    }
  }

  function readWhileMatching(reg) {
    let startIndex = i;
    while(i < content.length && reg.test(content[i])) {
      i++;
    }
    return content.slice(startIndex, i);
  }

  function skipWhitespace() {
    readWhileMatching(/[\s\n]/);
  }
}

function generate(ast) {

  const code = {
    variables: [],
    create: [],
    destroy: [],
  };

  let counter = 1;

  function traverse(node, parent) {
    switch(node.type) {
      case 'Element': {
        const variableName = `${node.name}_${counter++}`;
        code.variables.push(variableName);
        code.create.push(
          `${variableName} = element('${node.name}')`
        )
        node.attributes.forEach(attribute => {
          traverse(attribute, variableName);
        });

        node.children.forEach(child => {
          traverse(child, variableName);
        });

        code.create.push(`append(${parent}, ${variableName})`);
        code.destroy.push(`detach(${variableName})`);
        break;
      }
      case 'Text': {
        const variableName = `txt_${counter++}`;
        code.variables.push(variableName);
        code.create.push(`${variableName} = text('${node.value}');`);
        code.create.push(`append(${parent}, ${variableName})`);
        code.destroy.push(`detach(${variableName})`);
        break;
      }
      case "Attribute": {
        if (node.name.startsWith("on:")) {
          const eventName = node.name.slice(3);
          const eventHandler = node.value.name;
          const eventNameCall = `${eventName}_${counter++}`;
          code.variables.push(eventNameCall);
          code.create.push(
            `${eventNameCall} = listen(${parent}, "${eventName}", ${eventHandler})`
          );
          code.destroy.push(`${eventNameCall}()`);
        }
        break;
      }
    }
  }

  ast.html.forEach((fragment) => traverse(fragment, 'target'));

  return `
    function element(name) {
      return document.createElement(name);
    }

    function text(data) {
      return document.createTextNode(data);
    }

    function append(target, node) {
      target.appendChild(node);
    }

    function detach(node) {
      if (node.parentNode) {
        node.parentNode.removeChild(node);
      }
    }

    export function listen(node, event, handler) {
      node.addEventListener(event, handler);
      return () => node.removeEventListener(event, handler);
    }
    
    export default function() {
      ${code.variables.map(v => `let ${v};`).join('\n')}

      ${escodegen.generate(ast.script)}

      var lifecycle = {
        create(target) {
          ${code.create.join('\n')}
        },
        destroy(target) {
          ${code.destroy.join('\n')}
        }
      };
      return lifecycle;
    }
  `;
}

bootstrap();

小结

本章我们实现了:

  • 对html标签进行递归解析
  • 对编译后的代码进行格式化的美化操作