构建引擎: 打造小程序编译器

本节概述

经过前面章节的学习,我们已经将一个小程序页面渲染出来并实现了双线程的通信。本节开始,我们将针对用户编写的小程序代码,通过编译器构建成我们最终需要的形式,主要包括:

  • 配置文件 config.json
  • 页面渲染脚本 view.js
  • 页面样式文件 style.css
  • 逻辑脚本文件 logic.js

环境准备

小程序编译器我们将通过一个 CLI 工具的形式来实现,关于CLI实现相关的细节不是本小册的内容,这里我们就不展开了,我们通过 commander 工具包来进行命令行工具的管理操作。关于 commander 包的细节大家感兴趣可以前往其文档查看: commander

下载完成包后我们在入口文件处使用包来创建一个命令行程序:

ts 复制代码
import { program } from 'commander';
import { build } from './commander/build';

const version = require('../package.json').version;

program.version(version)
  .usage('[command] [options]');

program.command('build [path]')
  .description('编译小程序')
  .action(build);

program.parse(process.argv);

接下来我们主要就是针对于 build 函数进行详细的实现。

配置文件编译

配置文件的编译算是整个编译器最简单的部分,只需要读取到小程序目录下的 project.config.js 配置文件和 app.json 应用配置文件,以及每个页面下的 *.json 页面配置并组合即可;

ts 复制代码
import fse from 'fs-extra';

// 这里省略了类型定义,大家可以前往本小节代码仓库查看
const pathInfo: IPathInfo = {};
const configInfo: IConfigInfo = {};

export function saveEnvInfo() {
  savePathInfo();
  saveProjectConfig();
  saveAppConfig();
  saveModuleConfig();
}
function savePathInfo() {
  // 小程序编译目录
  pathInfo.workPath = process.cwd();
  // 小程序输出目录
  pathInfo.targetPath = `${pathInfo.workPath}/dist`;
}
function saveProjectConfig() {
  // 小程序项目配置文件
  const filePath = `${pathInfo.workPath}/project.config.json`;
  const projectInfo = fse.readJsonSync(filePath);
  configInfo.projectInfo = projectInfo;
}
function saveAppConfig() {
  // 小程序 app.json 配置文件
  const filePath = `${pathInfo.workPath}/app.json`;
  const appInfo = fse.readJsonSync(filePath);
  configInfo.appInfo = appInfo;
}
function saveModuleConfig() {
  // 处理每个页面的页面配置: pages/xx/xx.json
  const { pages } = configInfo.appInfo!;
  // 将页面配置组合成 [页面path]: 配置信息 的形式
  configInfo.moduleInfo = {};
  pages.forEach(pagePath => {
    const pageConfigFullPath = `${pathInfo.workPath}/${pagePath}.json`;
    const pageConfig = fse.readJsonSync(pageConfigFullPath);
    configInfo.moduleInfo![pagePath] = pageConfig;
  });
}

// 获取输出路径
export function getTargetPath() {
  return pathInfo.targetPath!;
}

// 获取项目编译路径
export function getWorkPath() {
  return pathInfo.workPath!;
}

// 获取app配置
export function getAppConfigInfo() {
  return configInfo.appInfo!;
}

// 获取页面模块配置
export function getModuleConfigInfo() {
  return configInfo.moduleInfo;
}

// 获取小程序AppId
export function getAppId() {
  return configInfo.projectInfo!.appid;
}

最终我们根据上面解析出的配置内容组合成编译后的配置文件即可:

ts 复制代码
export function compileConfigJSON() {
  const distPath = getTargetPath();
  const compileResultInfo = {
    app: getAppConfigInfo(),
    modules: getModuleConfigInfo(),
  };

  fse.writeFileSync(
    `${distPath}/config.json`,
    JSON.stringify(compileResultInfo, null, 2),
  );
}

WXML 文件编译

这里我们最终会使用vue来渲染小程序的UI页面,所以这里会将 WXML 文件编译成 vue 的产物的模式。

这里主要的点是将小程序 WXML 文件的一些语法转化为 vue 的格式,如:

  • wx:if => v-if
  • wx:for => v-for
  • wx:key => :key
  • style 解析成 v-bind:style 并匹配内部的 {``{}} 动态数据
  • {{}} 动态引用数据 => v-bind:xxx
  • bind* 事件绑定 => v-bind:* 并最终由组件内部管理事件触发

当然除了上述语法的转化外,我们还需要将对应的组件转化为自定义的组件格式,方便后续我们统一实现组件库管理;

对于 WXML 文件的解析,我们会使用 vue-template-compiler 包中的模版解析算法来进行,这块内容这里我们就不展开了,完整文件大家可以前往 vue-template-compiler 查看;

我们将使用到 vue-template-compiler 中的 parseHTML 方法将 WXML 转化为 AST 语法树,并在转化过程中对节点进行解析处理。 为了便于理解 parseHTML 函数,我们通过一个例子来看看 parseHTML 会处理成什么样子:

html 复制代码
<view class="container"></view>

这个节点会被解析成下面的形式:

json 复制代码
{
  "tag": "view",
  "attrs" [
    { "name": "class", value: "container" }
  ]
  // ... 还有别的一些信息,如当前解析位置相关的信息等
}

现在我们先来将 WXML 模版转化为 Vue 模版格式:

ts 复制代码
export function toVueTemplate(wxml: string) {
  const list: any = [];
  parseHTML(wxml, {
    // 在解析到开始标签的时候会调用,会将解析到的标签名称和属性等内容传递过来
    start(tag, attrs, _, start, end) {
      // 从原始字符串中截取处当前解析的字符串,如 <view class="container">
      const startTagStr = wxml.slice(start, end);
      // 处理标签转化
      const tagStr = makeTagStart({
        tag,
        attrs,
        startTagStr
      });
      list.push(tagStr);
    },
    chars(str) {
      list.push(str);
    },
    // 在处理结束标签是触发: 注意自闭合标签不会触发这里,所以需要在开始标签的地方进行单独处理
    end(tag) {
      list.push(makeTagEnd(tag));
    }
  });

  return list.join('');
}
ts 复制代码
// 小程序特定的组件,这里我们暂时写死几个
const tagWhiteList = ['view', 'text', 'image', 'swiper-item', 'swiper', 'video'];

export function makeTagStart(opts) {
  const { tag, attrs, startTagStr } = opts;
  
  if (!tagWhiteList.includes(tag)) {
    throw new Error(`Tag "${tag}" is not allowed in miniprogram`);
  }

  // 判断是否为自闭合标签,自闭合标签需要直接处理成闭合形式的字符串
  const isCloseTag = /\/>/.test(startTagStr);
  // 将tag转化为特定的组件名称,后续针对性的开发组件
  const transTag = `ui-${tag}`;
  // 转化 props 属性
  const propsStr = getPropsStr(attrs);

  // 拼接字符串
  let transStr = `<${transTag}`;
  if (propsStr.length) {
    transStr += ` ${propsStr}`;
  }

  // 自闭合标签直接闭合后返回,因为后续不会触发其end逻辑了
  return `${transStr}>${isCloseTag ? `</${transTag}>` : ''}`;
}

export function makeTagEnd(tag) {
  return `</ui-${tag}>`;
}

// [{name: "class", value: "container"}]
function getPropsStr(attrs) {
  const attrsList: any[] = [];
  attrs.forEach((attrInfo) => {
    const { name, value } = attrInfo;
    
    // 如果属性名时 bind 开头,如 bindtap 表示事件绑定
    // 这里转化为特定的属性,后续有组件来触发事件调用
    if (/^bind/.test(name)) {
      attrsList.push({
        name: `v-bind:${name}`,
        value: getFunctionExpressionInfo(value)
      });
      return;
    }

    // wx:if 转化为 v-if  => wx:if="{{status}}" => v-if="status"
    if (name === 'wx:if') {
      attrsList.push({
        name: 'v-if',
        value: getExpression(value)
      });
      return;
    }

    // wx:for 转化为 v-for => wx:for="{{list}}" => v-for="(item, index) in list"
    if (name === 'wx:for') {
      attrsList.push({
        name: 'v-for',
        value: getForExpression(value)
      });
      return;
    }

    // 转化 wx:key => wx:key="id" => v-bind:key="item.id"
    if (name === 'wx:key') {
      attrsList.push({
        name: 'v-bind:key',
        value: `item.${value}`
      });
      return;
    }

    // 转化style样式
    if (name === 'style') {
      attrsList.push({
        name: 'v-bind:style',
        value: getCssRules(value),
      });
      return;
    }

    // 处理动态字符串属性值
    if (/^{{.*}}$/.test(value)) {
      attrsList.push({
        name: `v-bind:${name}`,
        value: getExpression(value),
      });
      return;
    }

    attrsList.push({
      name: name,
      value: value,
    });
  });

  return linkAttrs(attrsList);
}

// 将属性列表再拼接为字符串属性的形式: key=value
function linkAttrs(attrsList) {
  const result: string[] = [];
  attrsList.forEach(attr => {
    const { name, value } = attr;
    if (!value) {
      result.push(name);
      return;
    }

    result.push(`${name}="${value}"`);
  });

  return result.join(' ');
}

// 解析小程序动态表达式
function getExpression(wxExpression) {
  const re = /\{\{(.+?)\}\}/;
  const matchResult = wxExpression.match(re);
  const result = matchResult ? matchResult[1].trim() : '';
  return result;
}

function getForExpression(wxExpression) {
  const listVariableName = getExpression(wxExpression);
  return `(item, index) in ${listVariableName}`;
}

// 将css样式上的动态字符串转化: style="width: 100%;height={{height}}" => { width: '100%', height: height }
function getCssRules(cssRule) {
  const cssCode = cssRule.trim();
  const cssRules = cssCode.split(';');
  const list: string[] = [];
  
  cssRules.forEach(rule => {
    if (!rule) {
      return;
    }

    const [name, value] = rule.split(':');
    const attr = name.trim();
    const ruleValue = getCssExpressionValue(value.trim());

    list.push(`'${attr}':${ruleValue}`)
  });

  return `{${list.join(',')}}`;
}

export function getCssExpressionValue(cssText: string) {
  if (!/{{(\w+)}}(\w*)\s*/g.test(cssText)) {
    return `'${cssText}'`;
  }

  // 处理{{}}表达式
  // 例如: '{{name}}abcd' => 转化后为 name+'abcd'
  const result = cssText.replace(/{{(\w+)}}(\w*)\s*/g, (match, p1, p2, offset, string) => {
    let replacement = "+" + p1;
    
    if (offset === 0) {
      replacement = p1;
    }
    if (p2) {
      replacement += "+'" + p2 + "'";
    }
    if (offset + match.length < string.length) {
      replacement += "+' '";
    }
    return replacement;
  });
  return result;
}

// 解析写在wxml上的事件触发函数表达式
// 例如: tapHandler(1, $event, true) => {methodName: 'tapHandler', params: [1, '$event', true]}
export function getFunctionExpressionInfo(eventBuildInfo: string) {
  const trimStr = eventBuildInfo.trim();
  const infoList = trimStr.split('(');
  const methodName = infoList[0].trim();

  let paramsInfo = '';
  if (infoList[1]) {
    paramsInfo = infoList[1].split(')')[0];
  }

  // 特殊处理$event
  paramsInfo = paramsInfo.replace(/\$event/, `'$event'`);
  
  return `{methodName: '${methodName}', params: [${paramsInfo}]}`
}

经过上面步骤的处理之后,我们的 WXML 就变成了 vue 模版文件的格式,现在我们只需要直接调用vue的编译器进行转化即可;

最后转化的vue代码我们需要通过 modDefine 函数包装成一个模块的形式,对应前面小节中我们的模块加载部分;

ts 复制代码
import fse from 'fs-extra';
import { getWorkPath } from '../../env';
import { toVueTemplate } from './toVueTemplate';
import { writeFile } from './writeFile';
import * as vueCompiler from 'vue-template-compiler';
import { compileTemplate } from '@vue/component-compiler-utils';

// 将项目中的所有 pages 都进行编译,moduleDep 实际就是每个页面模块的列表:
// { 'pages/home/index': { path, moduleId } }
export function compileWXML(moduleDep: Record<string, any>) {
  const list: any[] = [];
  for (const path in moduleDep) {
    const code = compile(path, moduleDep[path].moduleId);
    list.push({
      path,
      code
    });
  }
  writeFile(list);
}

function compile(path: string, moduleId) {
  const fullPath = `${getWorkPath()}/${path}.wxml`;
  const wxmlContent = fse.readFileSync(fullPath, 'utf-8');
  // 先把 wxml 文件转化为 vue 模版文件内容
  const vueTemplate = toVueTemplate(wxmlContent);
  // 使用 vue 编译器直接编译转化后的模版字符串
  const compileResult = compileTemplate({
    source: vueTemplate,
    compiler: vueCompiler as any,
    filename: ''
  });
  // 将页面代码包装成模块定义的形式
  return `
    modDefine('${path}', function() {
      ${compileResult.code}
      Page({
        path: '${path}',
        render: render,
        usingComponents: {},
        scopedId: 'data-v-${moduleId}'
      });
    })
  `;
}

WXSS 样式文件编译

对于样式文件我们需要处理:

  1. 将 rpx 单位转化为 rem 单位进行适配处理
  2. 使用 autoprefixer 添加厂商前缀提升兼容性
  3. 使用 postcss 插件为每个样式选择器添加一个scopeId,确保样式隔离

这里我们也将会使用 postcss 将样式文件解析成 AST 后对每个样式树进行处理。

ts 复制代码
import fse from 'fs-extra'; 
import { getTargetPath, getWorkPath } from '../../env';
const postcss = require('postcss');
const autoprefixer = require('autoprefixer');

export async function compileWxss(moduleDeps) {
  // 处理全局样式文件 app.wxss
  let cssMergeCode = await getCompileCssCode({
    path: 'app',
    moduleId: ''
  });
  
  for (const path in moduleDeps) {
    cssMergeCode += await getCompileCssCode({
      path,
      moduleId: moduleDeps[path].moduleId,
    });
  }

  fse.writeFileSync(`${getTargetPath()}/style.css`, cssMergeCode);
}

async function getCompileCssCode(opts: { path: string, moduleId: string }) {
  const { path, moduleId } = opts;
  const workPath = getWorkPath();
  const wxssFullPath = `${workPath}/${path}.wxss`;
  
  const wxssCode = fse.readFileSync(wxssFullPath, 'utf-8');
  // 转化样式文件为ast
  const ast = postcss.parse(wxssCode);
  ast.walk(node => {
    if (node.type === 'rule') {
      node.walkDecls(decl => {
        // 将rpx单位转化为rem,方便后面适配
        decl.value = decl.value.replace(/rpx/g, 'rem');
      });
    }
  });

  const tranUnit = ast.toResult().css;
  // 使用autoprefix 添加厂商前缀提高兼容性
  // 同时为每个选择器添加 scopeId 
  return await transCode(tranUnit, moduleId);
}

// 对css代码进行转化,添加厂商前缀,添加scopeId进行样式隔离
function transCode(cssCode, moduleId) {
  return new Promise<string>((resolve) => {
    postcss([
      addScopeId({ moduleId }),
      autoprefixer({ overrideBrowserslist: ['cover 99.5%'] })
    ])
     .process(cssCode, { from: undefined })
     .then(result => {
        resolve(result.css + '\n');
     })
  })
}

// 实现一个给选择器添加 scopedId的插件
function addScopeId(opts: { moduleId: string }) {
  const { moduleId } = opts;

  function func() {
    return {
      postcssPlugin: 'addScopeId',
      prepare() {
        return {
          OnceExit(root) {
            root.walkRules(rule => {
              if (!moduleId) return;
              if (/%/.test(rule.selector)) return;
              // 伪元素
              if (/::/.test(rule.selector)) {
                rule.selector = rule.selector.replace(/::/g, `[data-v-${moduleId}]::`);
                return;
              }
              rule.selector += `[data-v-${moduleId}]`;
            })
          }
        }
      }
    }
  }
  func.postcss = true;
  return func;
}

编译小程序逻辑JS

对于js逻辑代码的编译最终只需要使用 babel 进行一下编辑即可,但是我们需要做一些处理:

  1. 对于Page函数前面小节介绍过它有两个参数,第二个主要是一些编译信息,如 path,因此我们需要在编译器给Page函数注入
  2. 对于依赖的JS文件需要深度递归进行编译解析

这里我们先使用 babel 将js文件解析成AST,然后便利找到 Page 函数的调用给它添加第二个参数即可,同时使用一个集合管理已经编译的文件,避免递归重复编译

ts 复制代码
import fse from 'fs-extra';
import path from 'path';
import * as babel from '@babel/core';
import { walkAst } from './walkAst';
import { getWorkPath } from '../../env';

// pagePath: "pages/home/index"
export function buildByPagePath(pagePath, compileResult: any[]) {
  const workPath = getWorkPath();
  const pageFullPath = `${workPath}/${pagePath}.js`;
  
  buildByFullPath(pageFullPath, compileResult);
}

export function buildByFullPath(filePath: string, compileResult: any[]) {
  // 检查当前js是否已经被编译过了
  if (hasCompileInfo(filePath, compileResult)) {
    return;
  }

  const jsCode = fse.readFileSync(filePath, 'utf-8');
  const moduleId = getModuleId(filePath);
  const compileInfo = {
    filePath,
    moduleId,
    code: ''
  };
  
  // 编译为 ast: 目的主要是为 Page 调用注入第二个个模块相关的参数,以及深度的递归编译引用的文件
  const ast = babel.parseSync(jsCode);
  walkAst(ast, {
    CallExpression: (node) => {
      // Page 函数调用
      if (node.callee.name === 'Page') {
        node.arguments.push({
          type: 'ObjectExpression',
          properties: [ 
            {
              type: 'ObjectProperty',
              method: false,
              key: {
                type: 'Identifier',
                name: 'path',
              },
              computed: false,
              shorthand: false,
              value: {
                type: 'StringLiteral',
                extra: {
                  rawValue: `'${moduleId}'`,
                  raw: `'${moduleId}'`,
                },
                value: `'${moduleId}'`
              }
            }
          ]
        });
      }
      // require 函数调用,代表引入依赖脚本
      if (node.callee.name === 'require') {
        const requirePath = node.arguments[0].value;
        const requireFullPath = path.resolve(filePath, '..', requirePath);
        const moduleId = getModuleId(requireFullPath);
        
        node.arguments[0].value = `'${moduleId}'`;
        node.arguments[0].extra.rawValue = `'${moduleId}'`;
        node.arguments[0].extra.raw = `'${moduleId}'`;
        
        // 深度递归编译引用的文件
        buildByFullPath(requireFullPath, compileResult);
      }
    }
  });
  
  // 转化完之后直接使用 babel 将ast转化为js代码
  const {code: codeTrans } = babel.transformFromAstSync(ast, null, {});
  compileInfo.code = codeTrans;
  compileResult.push(compileInfo);
}

// 判断是否编译过了
function hasCompileInfo(filePath, compileResult) {
  for (let idx = 0; idx < compileResult.length; idx++) {
    if (compileResult[idx].filePath === filePath) {
      return true;
    }
  }
  return false;
}
// 获取模块ID:实际就是获取一个文件相对当前跟路径的一个路径字符串
function getModuleId(filePath) {
  const workPath = getWorkPath();
  const after = filePath.split(`${workPath}/`)[1];
  return after.replace('.js', '');
}

编译完成后,我们在输出之前,也是需要将每个js文件也使用 modDefine 函数包装成一个个的模块:

ts 复制代码
import { getAppConfigInfo, getWorkPath, getTargetPath } from '../../env';
import { buildByPagePath, buildByFullPath } from './buildByPagePath';
import fse from 'fs-extra';

export function compileJS() {
  const { pages } = getAppConfigInfo();  
  const workPath = getWorkPath();
  // app.js 文件路径
  const appJsPath = `${workPath}/app.js`;
  const compileResult = [];

  // 编译页面js文件
  pages.forEach(pagePath => {
    buildByPagePath(pagePath, compileResult);
  });

  // 编译app.js
  buildByFullPath(appJsPath, compileResult);
  writeFile(compileResult);
}

function writeFile(compileResult) {
  let mergeCode = '';
  compileResult.forEach(compileInfo => {
    const { code, moduleId } = compileInfo;

    // 包装成模块的形式
    const amdCode = `
      modDefine('${moduleId}', function (require, module, exports) {
        ${code}
      });
    `;
    mergeCode += amdCode;
  });

  fse.writeFileSync(`${getTargetPath()}/logic.js`, mergeCode);
}

到这里我们对于小程序的各个部分的编译就完成了,最后只需要在入口命令的build 函数中分别调用这些模块的编译函数即可。

本小节代码已上传至github,可以前往查看详细内容: mini-wx-app

相关推荐
哎呦你好8 分钟前
【CSS】Grid 布局基础知识及实例展示
开发语言·前端·css·css3
凌辰揽月11 分钟前
8分钟讲完 Tomcat架构及工作原理
java·架构·tomcat
盛夏绽放17 分钟前
接口验证机制在Token认证中的关键作用与优化实践
前端·node.js·有问必答
zhangxingchao33 分钟前
Jetpack Compose 之 Modifier(中)
前端
JarvanMo34 分钟前
理解 Flutter 中 GoRouter 的context.push与context.go
前端
pe7er39 分钟前
使用 Vue 官方脚手架创建项目时遇到 Node 18 报错问题的排查与解决
前端·javascript·vue.js
绝无仅有39 分钟前
对接三方SDK开发过程中的问题排查与解决
后端·面试·架构
搬砖的小码农_Sky41 分钟前
XILINX Ultrascale+ Kintex系列FPGA的架构
fpga开发·架构
星始流年43 分钟前
前端视角下认识AI Agent
前端·agent·ai编程
pe7er1 小时前
使用 types / typings 实现全局 TypeScript 类型定义,无需 import/export
前端·javascript·vue.js