实现小程序的单文件开发模式
一个微信小程序页面(Page)/组件(Component)通常由四个文件组成:wxml(模版)、wxss(样式)、js(逻辑)、json(配置),而在大部分情况下将一个页面/组件分为 4 个文件过于冗余,这篇文章教大家如何实现小程序的单文件开发模式
单文件模版
我定义了一个单文件模版,只要像下面一样写,就能被我的 webpack loader 识别并处理成微信的文件结构:
<template>
<index-com></index-com>
</template>
<script>
Page({});
</script>
<style></style>
<script mp-type="json">
export default {
usingComponents: {
"index-com": "component/index_com/index_com"
}
};
</script>
文件结构类似于 vue 的形式,template 包裹的会被处理为.wxml 的内容,script 标签对应.js,style 对应.wxss 文件,加上my-type="json"
属性的会被处理生成.json 文件。
需要注意的是,我这里的 json 并不是真正的 json,而是export default
一个对象,再被 loader 处理为 json 格式。这样做的好处有两点,第一:在 script 标签内直接写 json,有些代码检查插件会报错;第二,改为对象形式可以更方便的添加注释
loader 部分
webpack loader 的强大之处在于它不仅仅能处理 js 文件,还能处理其他任何格式的文件,只要你有相对应的 loader
loader 的写法也很简单,简单来看就是一个函数,接收 source(字符串形式的文件内容),再返回你处理过后的结果(或者通过 this.callback)。
这里我写了一个名为 sfm-loader(single file miniprogram)的 loader
loader 单纯的返回处理后的结果只会生成.js 文件,而小程序需要的是 4 个文件,在这里,我使用 loader 的 emitFile 方法生成.json 和.wxml 文件,利用 mini-css-extract-plugin 产生.wxss 文件
生成.json 和.wxml 文件
// loader
module.exports = function(source) {
const emitPath = path
.relative(`${this.rootContext}/src`, this.resourcePath)
.replace(/\..*/, "");
const json = selector(source, { type: "json" });
this.emitFile(`${emitPath}.json`, getJsonStr(json));
const template = selector(source, { type: "template" });
this.emitFile(`${emitPath}.wxml`, template);
return ``;
};
很简单,计算出生成文件的路径,再拿到相应的代码块,执行 this.emitFile 即可
其中 selector 的实现如下:
const posthtml = require("posthtml");
const select = (source, query = {}) => {
let result;
if (!"type" in query) {
return result;
}
const tree = posthtml().process(source, {
sync: true
}).tree;
const { type } = query;
tree.forEach(block => {
switch (type) {
case "json": {
let mpType = "";
try {
mpType = block.attrs["mp-type"];
} catch (error) {}
if (block.tag === "script" && mpType === "json") {
result = block;
}
break;
}
case "script": {
let mpType = "";
try {
mpType = block.attrs["mp-type"];
} catch (error) {}
if (block.tag === "script" && mpType !== "json") {
result = block;
}
break;
}
default:
if (block.tag === type) {
result = block;
}
}
});
if (!result) {
return "";
}
if (type === "template") {
return posthtml().process(result.content, {
sync: true,
skipParse: true
}).html;
} else {
return result.content.join("");
}
};
我使用了 posthtml 库来解析标签,也可以使用我之前写的 parser,为了更加稳定和便于维护,我在这里直接就用成熟的代码库
解析标签之后通过标签名和属性确定各个代码块并返回。由于 template 内的标签都被解析了,所以需要再转化为字符串形式
而 json 部分,获取的代码块是像下面的格式:
export default {
usingComponents: {}
};
在 json 文件中,我们不需要 export default,并且对象也要转换为 json 格式,所以我们不能直接生成 json 文件,而是通过 getJsonStr 方法:
const parser = require("@babel/parser");
const generate = require("@babel/generator")["default"];
const traverse = require("@babel/traverse")["default"];
const t = require("@babel/types");
const helpers = (module.exports = {});
helpers.getJson = source => {
let json = {};
const ast = parser.parse(source, {
sourceType: "module"
});
traverse(ast, {
ExportDefaultDeclaration(rootPath) {
let declarationPath = rootPath.get("declaration");
if (t.isObjectExpression(declarationPath)) {
const code = generate(declarationPath.node).code;
json = eval("(" + code + ")");
}
}
});
return json;
};
helpers.getJsonStr = source => {
return JSON.stringify(helpers.getJson(source), null, 2);
};
先通过@babel/parser 将代码转化为 ast 树,再使用@babel/traverse 遍历 ast 树,拿到 export default 后的对象,用@babel/traverse 再转化为字符串(这部分过程也可以通过简单的字符串匹配)。现在我们拿到字符串依然不是标准的 json 格式,我使用了 eval 方法,将这部分字符串转化为 js 对象,再调用 JSON.stringify 生成标准的 json 格式字符串。
这里,可能很多人一看到 eval 就会感到不放心,认为它是一个不安全的函数,其实,我觉得,只要你知道你在做什么,完全是可以使用的,webpack 在很多种 devtool 中也是直接使用了 eval 函数
生成.wxss 文件
再来看 wxss 部分,我采用了 mini-css-extract-plugin 去提取 css 并生成 wxss。很简单:我们将 style 标签内的 css 部分先交由 css-loader(这里可以拓展 less,sass,stylus 等语言),再由 mini-css-extract-plugin 的 loader 处理,配合 webpack 的配置:
plugins: [
new MiniCssExtractPlugin({
filename: "[name].wxss"
})
];
最后就会生成我们需要的 wxss 文件。
好,开始编码,首先思考如何让 css-loader 拿到 style 标签内的内容:
const basename = path.basename(this.resourcePath);
const styleQuery = JSON.stringify({ type: "style" });
const style = `const __v2mp__style__ = require('!!mini-css-extract-plugin/dist/loader.js!css-loader!sfm?${styleQuery}!./${basename}');`;
我们利用 webpack 的特殊写法,让这个文件再过一遍 sfm-loader、css-loader、mini-css-extract-plugin/dist/loader.js。
在 sfm-loader 最前面我们加上判断逻辑,如果 this.query 存在,则按照 query 拿到相应的代码片段:
if (this.query) {
return selector(source, JSON.parse(this.query.slice(1)));
}
生成.js 文件
webapck 是为 web 端设计的,而小程序和 web 端的运行环境不同,小程序不存在 window 的概念,并且可以直接识别 require 语法
const script = selector(source, { type: "script" });
return `${style}\n${script}`;
我们先取到 script 部分,在 loader 的最后和 style 一起返回;这部分代码会被 mini-css-extract-plugin 和我们自定义的 plugin 处理。
plugin 部分
自定义 plugin 如下:
const path = require("path");
const ConcatSource = require("webpack-sources").ConcatSource;
function V2mpPlugin(options) {}
const name = "V2mpPlugin";
V2mpPlugin.prototype.apply = function(compiler) {
compiler.hooks.emit.tapAsync(name, (compilation, callback) => {
compilation.chunkGroups.forEach((chunkGroup, index) => {
chunkGroup.chunks.forEach(chunk => {
if (chunk.id === "manifest") {
index === 0 && chunkHandler(chunk, compilation, true);
} else {
chunkHandler(chunk, compilation, false);
}
});
});
callback();
});
};
function chunkHandler(chunk, compilation, isRuntime) {
const files = chunk.files.filter(file => {
return path.extname(file) === ".js";
});
files.forEach(file => {
const originalSource = compilation.assets[file];
const relativePath = path.relative(path.dirname(file), "./manifest.js");
const source = new ConcatSource();
if (isRuntime) {
source.add("const window = {};\n");
source.add(originalSource);
source.add("\nmodule.exports=window;");
} else {
source.add(`const window=require('${relativePath}');\n`);
source.add(originalSource);
}
compilation.assets[file] = source;
});
}
这个 plugin 做的事很简单,就是遍历要生成的 js 文件,判断是否为 manifest 文件,如果是,则加上const window = {};\n
和\nmodule.exports=window;
;如果不是,则加上const window=require('${relativePath}');\n
,relativePath 是该文件到 manifest 文件的相对路径
因为我们在用 webpack 打包时,通过挂载在 window 上的 webpackJsonp 函数去加载各个 chunk,由于小程序不存在 window 全局变量,我们在 manifest 人为创建一个 window 变量,并在各个 chunk 中引入
webpack 配置
基本 webpack 配置如下:
var path = require("path");
const V2mpPlugin = require("sfm/src/plugin.js");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const { getEntries } = require("sfm/src/entry");
module.exports = {
mode: "development",
devtool: "cheap-source-map",
entry: getEntries("./src/app.vue"),
module: {
rules: [
{
test: /\.vue$/,
use: [
{
loader: "sfm"
}
]
}
]
},
output: {
filename: "[name].js",
publicPath: "/",
path: path.resolve("dist")
},
optimization: {
noEmitOnErrors: false,
runtimeChunk: {
name: "manifest"
}
},
plugins: [
new V2mpPlugin(),
new MiniCssExtractPlugin({
filename: "[name].wxss"
})
]
};
将.vue 后缀的文件交由 sfm.loader 处理;生成 runtimeChunk;引入自定义 plugin 和 MiniCssExtractPlugin
这其中重要的一点就是 getEntries 函数,我们知道,webpack 默认是将所有文件打包成一个文件,但由于小程序的规则,我们需要一个页面/组件单独对应一个 js 文件,这就要求我们将每一个页面/组件都看作一个 chunk,在打包之前通过 getEntries 先获取到所有的 chunk:
const selector = require("./selector");
const path = require("path");
const fs = require("fs");
const { getJson } = require("./helpers");
const getComponents = (pagePath, rootPath) => {
const components = [];
const page = fs.readFileSync(pagePath).toString();
const pageJson = selector(page, { type: "json" });
const pageJsonContent = getJson(pageJson);
const usingComponents = pageJsonContent.usingComponents || {};
for (let key in usingComponents) {
const componentPath = path.join(
path.dirname(pagePath),
usingComponents[key]
);
components.push(path.relative(rootPath, componentPath));
}
return components;
};
module.exports.getEntries = appPath => {
const rootPath = path.join(process.cwd(), path.dirname(appPath));
let entryies = { app: path.resolve(appPath) };
const appAbPath = path.join(process.cwd(), appPath);
const app = fs.readFileSync(appAbPath).toString();
const appJson = selector(app, { type: "json" });
const appJsonContent = getJson(appJson);
const pages = appJsonContent.pages || [];
pages.forEach(page => {
const pagePath = `${path.join(rootPath, page)}.vue`;
entryies[page] = pagePath;
const components = getComponents(pagePath, rootPath);
components.forEach(component => {
entryies[component] = `${path.join(rootPath, component)}.vue`;
});
});
return entryies;
};
原理很简单,先获取到 app.js 里 json 配置里的 pages 属性,这就是小程序所有的页面;再遍历这些页面 json 配置的 usingComponents 属性,获取其引用的所有组件(这里没有做判重和判断循环引用)。最后返回的 entryies 包括了 app、所有的 page、所有的 components,这也就是 webpack 配置中的 entry 属性。