当前位置: 首页 > news >正文

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

本节概述

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

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

环境准备

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

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

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 页面配置并组合即可;

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.jsonconst { 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;
}

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

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 会处理成什么样子:

<view class="container"></view>

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

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

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

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('');
}
// 小程序特定的组件,这里我们暂时写死几个
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];}// 特殊处理$eventparamsInfo = paramsInfo.replace(/\$event/, `'$event'`);return `{methodName: '${methodName}', params: [${paramsInfo}]}`
}

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

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

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 后对每个样式树进行处理。

import fse from 'fs-extra'; 
import { getTargetPath, getWorkPath } from '../../env';
const postcss = require('postcss');
const autoprefixer = require('autoprefixer');export async function compileWxss(moduleDeps) {// 处理全局样式文件 app.wxsslet 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');// 转化样式文件为astconst 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 函数的调用给它添加第二个参数即可,同时使用一个集合管理已经编译的文件,避免递归重复编译

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 函数包装成一个个的模块:

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.jsbuildByFullPath(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

http://www.dtcms.com/a/265655.html

相关文章:

  • 边缘计算解决方案:电力作业行为图像识别
  • Mac电脑 触摸板增强工具 BetterTouchTool
  • Linux开发工具——gcc/g++
  • 虚拟机网络检查
  • 数据结构-栈的实现
  • 电动车信用免押小程序免押租赁小程序php方案
  • 数据库运维手册指导书
  • 移动端Html5播放器按钮变小的问题解决方法
  • Laravel8中使用phpword生成word文档
  • LeetCode--40.组合总和II
  • 【ArcGIS Pro】属性表咋不能编辑了?
  • wvp-GB28181-pro 项目 ZLMediaKit 部署 (Centos7)
  • XILINX Ultrascale+ Kintex系列FPGA的架构
  • R语言开发记录,二(创建R包)
  • vue-37(模拟依赖项进行隔离测试)
  • 《导引系统原理》-西北工业大学-周军-“2️⃣导引头的角度稳定系统”
  • 定时点击二次鼠标 定时点击鼠标
  • Node.js中exports与module.exports区别
  • DPDK开发环境配置
  • SpringCloud系列(49)--SpringCloud Stream消息驱动之实现生产者
  • 《Spring 中上下文传递的那些事儿》 Part 1:ThreadLocal、MDC、TTL 原理与实践
  • 使用 Docker Swarm 部署高可用集群指南
  • 副作用是什么?
  • DQL-3-聚合函数
  • lspci查看PCI设备详细信息
  • linux常用命令(10):scp命令(远程拷贝命令,复制文件到远程服务器)
  • PlatformIO 在使用 GitHub 上的第三方库
  • Spark 4.0的VariantType 类型以及内部存储
  • 云上堡垒:如何用AWS原生服务构筑坚不可摧的主机安全体系
  • java教程——初识guava(2)