PHP + Vite(with HMR)开发: 从Matecho开发过程说起
Hypertext processing时你在做什么? 有没有空? 可以来transformIndexHtml吗?
Zapic
2024-09-21 1

始因

我的博客一直都使用基于PHP的Typecho作为后端, 在PHP日渐式微的今天, 人们的目光更多的放在的Rust和Go之类的新兴语言上, Typecho社区也再也少见令人眼前一亮的新主题.
倒也不是说之前的那些主题就不能用, 只是都不对我的口味. 我想要的是非常纯粹的阅读体验, 当人读文章的时候, 他就只能看到文章. 这也就是我想自己做一个主题的原因, 做一个极致简陋, 逼人只能看文章的主题.
5年前这个计划就开始了, 但是由于时间和技术能力受限, 实在是做不出心里想要的效果, 只得搁置.
后来, 在本篇文章码下的半年前, 突然心血来潮, 直接开干. 恰逢MDUI发布了新的版本, 用上了新的Material Design, 在短短一星期之内就写出了最初的版本.

Make things interesting

在这过程中, 我觉得最有意思的部分正是: 将PHP与Vite集成, 使用最新的Web开发技术栈和构建工具链大幅提升开发体验.
听着好像就是1+1的问题, 实际上有很多难点:

  1. 如何使Vite处理PHP文件? PHP通过内嵌在HTML的特殊标记来向HTML中注入内容, Vite显然不能识别.
  2. 自动动态导入, JS和Web Component对于PHP都是透明的, 如何从PHP文件中分析需要导入的组件?
  3. Typecho的主题结构显然与普通的SPA/MPA网页不同, 如何集成?

前期调研

在Github上已经有很多将PHP与Vite集成的插件/方法.

  1. donnikitos/vite-plugin-php
  2. andrefelipe/vite-php-setup

表现出两种思路:

  1. 使用Vite的Build Hooks在transform过程中调用PHP, 将内容注入进HTML中后再交由Vite继续处理.
  2. 将PHP与Vite主动集成, PHP主动适配并完成部分Vite的工作(注入link和script src标签), 使浏览器能够从Vite拉取内容.

第二种方案对PHP侵入性太高, 需要修改PHP代码, 而且随着Vite更新, 注入方式可能会不同, 需要再次编写集成Vite的代码, 导致了PHP与Vite非常强的耦合, 故不选用.
而第一种方案听起来非常美好, 对于简单的项目足够使用了. 但是Typecho并不简单, 通过模板引入的方式要求整个Typecho的所有代码必须经过Vite处理才能正常使用, 而且PHP中有些功能依赖于Apache/Nginx的预处理, 比如伪静态, 这种不使用HTTP服务器的方案与实际情况存在巨大差异, 很可能导致问题.

大体架构

目前能找到的两种方案都被抛弃了, 我们需要思考自己的解决方案:

  1. 让PHP尽可能与Vite解耦, PHP中不应该存在针对Vite的特殊处理.
  2. 让Vite能够处理PHP中的HTML代码, 并正确注入Vite入口(link和script src标签).

故PHP代码应该让Apache处理, Vite可以将处理好的HTML代码交给Apache, 或者让Apache处理好PHP之后再对HTML进行transform.
在build之后PHP代码不应该与Vite继续交互, 但dev时Vite是必然存在的. 所以考虑分开处理dev和build:

  • 在dev时:

    flowchart LR
    原始代码 --> PHP --> Vite --> 浏览器

    这样可以在dev时获得Vite的一些特性, 例如HMR和按需编译.
    当然可以build --watch, 但是随着项目增大, build的时间在关掉所有优化压缩之后也变得越来越不可接受(~10s).

  • 在build时:

    flowchart LR
    原始代码 --> Vite --> PHP --> 浏览器

    这样在发布代码时可以直接分发编译后的代码, 不需要使用者自行编译.

实现

接下来就着手解决两个问题: Vite+PHP集成, 自动导入

Vite+PHP集成

dev和build需求不同, dev环境存在Vite, 而且需要快速返回修改; 而build要求对代码进行优化, 并打包JS模块提高兼容性. 故可以分类考虑两种情况的解决方案.

dev
在dev期间, PHP代码被完全处理后才交由Vite处理, 不需要处理PHP代码.
考虑实现一个proxy, 使其内容经过Vite处理管线.

首先就需要将文件交给Apache处理, 我们的文件都在自己的repo下面, 难道要每改一次就复制一次到Apache那边吗? 显然不是.
我们可以用软链接来实现这一切, 直接将src/链接到typecho/usr/themes/Matecho/.
不过为了覆盖更多的情况, 比如测试build后的结果, 我们应当考虑链接dist/.
ln -s /home/zapic/dist/ /var/www/typecho/usr/themes/Matecho
然后再在buildStart中复制这些文件并监听更改:

async buildStart(options) {
  if (env.command === "serve") {
    const files = await fg("src/**/*.php");
    try {
      await rm("dist/", { recursive: true });
    } catch (_) {
      //
    }
    await mkdir("dist/");
    await Promise.all(
      files.forEach(file => {
        this.addWatchFile(file);
        copyFile(file, file.replace("src/", "dist/"));
      })
    );
    await cp("public/", "dist/", { recursive: true }); // 我们的代码中需要扫描cover文件夹获得文章封面列表, 复制public到dist
  }
},
watchChange(id) {
  if (id.endsWith(".php")) {
    if (existsSync(id)) { // 如果文件还存在, 没被删除
      copyFile(id, id.replace("src/", "dist/"));
    } else { // 文件被删除
      rm(id.replace("src/", "dist/"));
    }
  }
},

这样就解决了第一步, 把文件交给Apache处理.
第二步是将Apache处理后的HTML交给Vite处理, 官方推荐的方法是自己用node起个HTTP服务器做Proxy, 但是有点点麻烦.
故在configureServer钩子中注入中间件, 并保证其优先级低于Vite所有内置的中间件, 这样便会在请求的URL无法找到内容的时候轮到我们, 然后就可以修改响应内容.
为了保证其优先级低于其他中间件, 需要返回一个回调:

// createTransformProxy impl & other code: https://github.com/KawaiiZapic/Matecho/blob/28680cab0e33920109e5d8c9b0688c782a93771f/plugins/Matecho.ts#L23
configureServer(server) {
  return () => {
    server.middlewares.use((req, res) => {
      const proxy = createTransformProxy(
        async (html, req) => {
          if (
            html
              .substring(0, 15)
              .toUpperCase()
              .startsWith("<!DOCTYPE HTML>") // Vite只能处理HTML5内容
          ) {
            try {
              return await server.transformIndexHtml(req.url, html); // 尝试让Vite对PHP处理后的内容进行转换
            } catch (e) {
              console.error(e);
              if (e instanceof Error) {
                setTimeout(() => {
                  server.hot.send({
                    type: "error",
                    err: {
                      message: e.message,
                      stack: e.stack
                    }
                  });
                }, 100);
              }
              // 炸了, 返回一个空的内容, 并注入Vite的代码, 以便显示错误
              return await server.transformIndexHtml(req.url, ""); 
            }
          } else {
            return html;
          }
        },
        {
          target: "http://" + (server.config.server.host || "localhost")
        }
      );
      proxy.web(req, res);
    });
  };
}

这样便完成了基本的transform, 我们的middleware中调用了transformIndexHtml, Vite就会像处理一般的SPA项目一样处理这段HTML, 我们就可以使用任意Vite插件方便我们的开发.
通过这小一百行代码, 我们一口气解决了dev环境中的两个问题: 如何让Vite处理PHP/如何将Vite集成到Typecho, 我们通过代理Apache响应直接绕过了这两个问题.
但是在build呢, build后不可能带上Vite, 该怎么办呢?

build
build时, 我们就需要自行处理PHP代码了.
首先我们需要使Vite处理我们的PHP文件, 注意到我们的项目与传统的MPA项目比较相近, 故可以考虑将项目类型设置成MPA并将所有PHP文件作为入口.

export default {
  appType: "mpa",
  build: {
     rollupOptions: {
       input: [...(await fg("src/**/*.php"))]
     }
  }
}

显然, Vite不支持PHP, 会抛出错误"Unknown file extension .php".
我们需要接管.php的加载过程:

resolveId(id) {
   if (id.endsWith(".php")) {
      return id.replace(".php", "_actual_php.html");
   }
},
async load(id) {
  if (id.endsWith("_actual_php.html")) {
    return (
      await readFile(id.replace("_actual_php.html", ".php"))
    ).toString();
  }
}

我们将其的后缀转换为.html后, Vite的HTML插件会参与处理这些PHP文件.
但是含有<?php块的HTML并不能被正常解析, 会导致Vite报错. 参考上面那些项目的解决方案后, 我们可以通过暂时剥离这些代码块并在Vite处理之后再注入回去.

const codeTokens: Record<string, string> = {};
// ...
async load(id) {
  if (id.endsWith("_actual_php.html")) {
    return (
      await readFile(id.replace("_actual_php.html", ".php"))
    ).toString()
    .replace(/<\?(?:php|).+?(\?>|$)/gis, match => {
      const token = "PHPCode" + hash(match) + Date.now();
      codeTokens[token] = match;
      return token;
    });
  }
},
transformIndexHtml(html, ctx) {
  let r = html;
  if (env.command !== "serve") {
    Object.entries(codeTokens).forEach(([token, code]) => {
      r = r.replaceAll(token, code);
    });
  }
  return r;
},
generateBundle: {
  order: "post",
  handler(opt, bundle, isWrite) {
    if (!isWrite) return;
    for (const key of Object.keys(bundle)) {
      const OutputBundle = bundle[key];
      if (key.endsWith("_actual_php.html")) {
        bundle[key].fileName = bundle[key].fileName
          .replace("_actual_php.html", ".php")
          .replace("src/", "");
      }
  }
}

load钩子内手动实现读取文件的逻辑, 在读取后直接剥离所有<?php, 并替换成占位符, 稍后在transformIndexHtml还原代码块.
占位符要适应绝大多数可能出现PHP代码块的地方, 实验证明, 一串由字母和数字组成的字符串就能适应绝大多数情况, 只要求PHP不破坏完整的HTML标签:

<span class="<?php if($active){ ?> active <?php } ?>"> <!--ok-->
<span class="<?php if($active){ ?> active"> <?php } else { ?>"><?php } ?> <!--error-->

最后在generateBundle钩子中将输出文件名还原为.php.
至此, 我们就完成了Vite针对PHP中HTML的transform, 而且支持CSS的HMR, HTML由于受到PHP的影响, 必须完全重载页面.

通过一个合适的方案, 我们自然而然的同时解决了如何让Vite处理PHP/如何将Vite集成到Typecho的问题, 在开发过程中我的体验也是丝般顺滑, 就像在写普通的PHP代码一样.

自动导入

Matecho所使用的MDUI是Web Component, 要求导入相应的文件才能正常显示. 当然, 可以一股脑的全部导入进项目里, 但是为了实现极快的加载速度, 还是需要分析每一页所需的组件再实现动态引入.

基本设计
为了确定分析哪些文件并与JS逻辑结合处理导入, Matecho中使用了Virtual Module实现这一切.
virtual:components/xxx决定分析哪个文件的导入情况后, 便可无缝的使用JS的import语法将其在任何地方导入. 这给了JS侧极高的自由性, 使得整个导入逻辑都是可以控制的.

// main.ts
import "virtual:components/header";
import "virtual:components/sidebar";
import "virtual:components/footer";
//...

// page/post.ts
import "virtual:components/post";

if (canComment) {
  import("virtual:components/comment");
}

实现
首先需要确保自己的Virtual Module被解析:

resolveId(id) {
  if (id.startsWith("virtual:components")) {
    return id;
  }
}

接着在load中分析并生成内容, 为了避免重复生成导入语句并考虑到项目的实际情况, 一个页面必然会引入header`sidebarfooterfunctions`, 故将这些文件引入的组件视为全局组件, 之后的页面会跳过引入这些组件.
避免重复事实上是强迫症, 实际上JS会处理重复导入, 并没有太大问题.

const AutoComponents = {
  preloaded: [] as string[],
  from: ["header", "sidebar", "footer", "functions"]
};
async load(id) {
  if (id.startsWith("virtual:components")) {
    const src = (
      await readFile(
        "src/" + id.replace("virtual:components/", "") + ".php"
      )
    ).toString();
    const isFromPreloaded = AutoComponents.from.includes(
      id.replace("virtual:components/", "")
    );
    return Array.from(
      new Set(Array.from(src.matchAll(/<mdui-([^<> ]+)/g)).map(v => v[1]))
    )
      .filter(v => !AutoComponents.preloaded.includes(v))
      .map(v => {
        if (v.includes("$")) return ""; // php dynamic tags
        if (isFromPreloaded) AutoComponents.preloaded.push(v);
        if (v.startsWith("icon-")) {
          return `import "@mdui/icons/${v.substring(5)}";`;
        } else {
          return `import "mdui/components/${v}";`;
        }
      })
      .join("\n");
  }
},

直接使用正则表达式提取出所有的<mdui-标签, 并分析其中的内容后, 生成import语句.
这样便可以实现自动分析导入了, 接着再在合适的地方导入这些Virtual Module:

// main.ts
import "virtual:components/header";
import "virtual:components/sidebar";
import "virtual:components/footer";
//...

// page/post.ts
import "virtual:components/post";

if (canComment) {
  import("virtual:components/comment");
}

就可以实现按需加载.

收工

至此, 我们已经实现了所有核心需求: Vite+PHP集成和自动导入. 这项工作极大的简化了Matecho的构建过程, 也让开发过程被现代前端工具链赋能, 整体体验如丝般顺滑. 而且我认为这种架构具有非常强的通用性, 稍加改造便能适应各种不同的PHP框架, 可以应用在Discuz之类的平台上. 虽然我早晚会换掉Typecho这个平台, 但是这次开发让我获得了不少的经验, 我已经变成Vite大手子了.

评论 1
ssxx #695

几天不看,换了 Material 大变样了!赞美 material

2024-09-28 16:27
回复
评论已关闭
发表评论
评论 取消回复
Copyright © 2024 Zapic's Blog