本节概述
经过前面章节的学习,我们已经将一个小程序页面渲染出来并实现了双线程的通信。本节开始,我们将针对用户编写的小程序代码,通过编译器构建成我们最终需要的形式,主要包括:
- 配置文件
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.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;
}
最终我们根据上面解析出的配置内容组合成编译后的配置文件即可:
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];
}
// 特殊处理$event
paramsInfo = 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 样式文件编译
对于样式文件我们需要处理:
- 将 rpx 单位转化为 rem 单位进行适配处理
- 使用 autoprefixer 添加厂商前缀提升兼容性
- 使用 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.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 进行一下编辑即可,但是我们需要做一些处理:
- 对于Page函数前面小节介绍过它有两个参数,第二个主要是一些编译信息,如
path
,因此我们需要在编译器给Page函数注入
- 对于依赖的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.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