Garlic Garlic

Svelte从入门到精通——IfBlock

发表于 阅读时长8分钟

特殊block

这是实现模块的最后一章,笔者会带大家编写如何实现一个简单的if逻辑判断。

首先我们需要在原来判断表达式的逻辑里,加上对特殊html标签的判断。

function parseExpression() {
- if (match("{")) {
+ if (match("{") && !match('{#')) {
    eat("{");
    const expression = parseJavaScript();
    eat("}");
    return {
      type: "Expression",
      expression,
    };
  }
}

添加解析特殊标签的逻辑。

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

parseBlock的具体内容:

function parseBlock() {
	if (match('{#')) {
	  if (match('{#if')) {
		eat('{#if')
		skipWhitespace();
		const expression = parseJavaScript();
		eat('}');
		const endTag = '{/if}';
		const block = {
		  type: 'IfBlock',
		  expression,
		  children: parseFragments(() => !match(endTag))
		}
		eat(endTag);
		return block;
	  }
	}
}

我们判断在{#if}{/if}中的内容为IfBlock。对于里面的内容,继续调用parseFragments进行解析。

完善generate里的traverse方法

function traverse(node, parent) {
  switch (node.type) {
    case "IfBlock": {
      const variableName = `if_block_${counter++}`;
      const funcName = `${variableName}_func`;
      const funcCallName = `${funcName}_call`;
      const expressionStr = escodegen.generate(node.expression);
      code.variables.push(variableName);
      code.variables.push(funcName);
      code.variables.push(funcCallName);
		  code.create.push(`
      ${funcName} = () => {
        if (${expressionStr}) {
          if (${funcCallName}) {return;}
          ${variableName} = element('span');
        `)
      node.children.forEach(subNode => {
        traverse(subNode, variableName)
      });

      code.create.push(`
        append(${parent}, ${variableName});
        ${funcCallName} = true;
        } else {
          ${funcCallName} = false;
          if (${variableName} && ${variableName}.parentNode) {
            detach(${variableName});
          }
        }
      }
      ${funcName}()
      `);
      
      code.destroy.push(`detach(${variableName})`);
      code.update.push(`${funcName}()`);
      
      break;
    }
  }
}

在App.svelte中测试一下

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

<button on:click={updateCount}>add</button>
count: {count}
{#if count > 2 && count < 5}hello{/if}

有了parseBlock方法,我们就可以在里面继续添加EachBlock、AwaitBlock等其他特殊标签的解析。

addBtn.addEventListener('click', function() {
  text = document.createTextNode('');
  const div = document.createElement('div');
  div.innerHTML = 'hello';
  
  box.appendChild(text);
  box.appendChild(div);
})

removeBtn.addEventListener('click', function() {
  if (text) {
    text.parentNode.removeChild(text.nextElementSibling);
    text.parentNode.removeChild(text);
  }
})

完整代码

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";
import * as estreewalker from "estree-walker";
import * as periscopic from "periscopic";

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
  const analysis = analyse(ast);
  return generate(ast, analysis);
}

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() ?? parseExpression() ?? parseBlock();
  }

  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 parseExpression() {
    if (match("{") && !match('{#')) {
      eat("{");
      const expression = parseJavaScript();
      eat("}");
      return {
        type: "Expression",
        expression,
      };
    }
  }

  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 parseBlock() {
    if (match('{#')) {
      if (match('{#if')) {
        eat('{#if')
        skipWhitespace();
        const expression = parseJavaScript();
        eat('}');
        const endTag = '{/if}';
        const block = {
          type: 'IfBlock',
          expression,
          children: parseFragments(() => !match(endTag))
        }
        eat(endTag);
        return block;
      }
    }
  }

  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 analyse(ast) {
  const result = {
    variables: new Set(),
    willChange: new Set(),
    useInTemplate: new Set(),
  };

  const { scope: rootScope, map, globals } = periscopic.analyze(ast.script);
  result.variables = new Set(rootScope.declarations.keys());
  result.rootScope = rootScope;
  result.map = map;

  let currentScope = rootScope;
  estreewalker.walk(ast.script, {
    enter(node) {
      if (map.has(node)) {
        currentScope = map.get(node);
      }
      if (
        node.type === "UpdateExpression" ||
        node.type === "AssignmentExpression"
      ) {
        const names = periscopic.extract_names(
          node.type === "UpdateExpression" ? node.argument : node.left
        );
        for (const name of names) {
          if (
            currentScope.find_owner(name) === rootScope ||
            globals.has(name)
          ) {
            result.willChange.add(name);
          }
        }
      }
    },
    leave(node) {
      if (map.has(node)) {
        currentScope = currentScope.parent;
      }
    },
  });

  function traverse(fragment) {
    switch (fragment.type) {
      case "Element":
        fragment.children.forEach((child) => traverse(child));
        break;
      case "Expression": {
        periscopic.extract_names(fragment.expression).forEach((name) => {
          result.useInTemplate.add(name);
        });
        break;
      }
    }
  }
  ast.html.forEach((fragment) => traverse(fragment));

  return result;
}


function generate(ast, analysis) {
  const code = {
    variables: [],
    create: [],
    update: [],
    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;
      }
      case "Expression": {
        const variableName = `exp_${counter++}`;
        const expressionStr = escodegen.generate(node.expression);
        code.variables.push(variableName);
        code.create.push(`${variableName} = text(${expressionStr})`);
        code.create.push(`append(${parent}, ${variableName});`);

        // 更新
        const names = periscopic.extract_names(node.expression);
        if (analysis.willChange.has(names[0])) {
          let condition = `changed.includes('${names[0]}')`;
          code.update.push(`if (${condition}) {
            ${variableName}.data = ${expressionStr};
          }`);
        }
        break;
      }
      case "IfBlock": {
        const variableName = `if_block_${counter++}`;
        const funcName = `${variableName}_func`;
        const funcCallName = `${funcName}_call`;
        const expressionStr = escodegen.generate(node.expression);
        code.variables.push(variableName);
        code.variables.push(funcName);
        code.variables.push(funcCallName);

        code.create.push(`
        ${funcName} = () => {
          if (${expressionStr}) {
            if (${funcCallName}) {return;}
            ${variableName} = element('span');
          `)
          node.children.forEach(subNode => {
            traverse(subNode, variableName)
          });

        code.create.push(`
          append(${parent}, ${variableName});
          ${funcCallName} = true;
          } else {
            ${funcCallName} = false;
            if (${variableName} && ${variableName}.parentNode) {
              detach(${variableName});
            }
          }
        }
        ${funcName}()
        `);
        
        code.destroy.push(`detach(${variableName})`);
        code.update.push(`${funcName}()`);
        
        break;
      }
    }
  }

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

  const { rootScope, map } = analysis;
  let currentScope = rootScope;
  estreewalker.walk(ast.script, {
    enter(node, parent) {
      if (map.has(node)) {
        currentScope = map.get(node)
      }
      if (node.type === 'UpdateExpression' || node.type === 'AssignmentExpression') {
        const names = periscopic
          .extract_names(
            node.type === 'UpdateExpression' ? node.argument : node.left
          )
          .filter(
            (name) =>
              currentScope.find_owner(name) === rootScope &&
              analysis.useInTemplate.has(name)
          );
        if (names.length > 0) {
          this.replace({
            type: 'SequenceExpression',
            expressions: [
              node,
              acorn.parseExpressionAt(`$$update(${JSON.stringify(names)})`, 0, {
                ecmaVersion: 2023,
              }),
            ],
          });
          this.skip();
        }
      }
    },
    leave(node) {
      if (map.has(node)) {
        currentScope = currentScope.parent;
      }
    }
  });

  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")}

      let collectChanges = [];
      
      function $$update(changed) {
        changed.forEach(c => collectChanges.push(c));
        lifecycle.update(collectChanges);
      }

      ${escodegen.generate(ast.script)}

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

bootstrap();

小结

本章我们实现了:

  • IfBlock的解析。

笔者在本章使用的实现方式存在一个缺点:IfBlock的内容外层多了一个span标签包裹。然而对于不是专门介绍框架实现的小册内容来说,瑕不掩瑜。