Skip to content

实现小程序的单文件开发模式 #16

@Bowen7

Description

@Bowen7

访问 https://bowencodes.com 以获得最佳体验

一个微信小程序页面(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": "../../components/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 属性。

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions