前言#
在开始实现之前,我得说,本篇要实现的功能用rust + WebAssembly来实现只能装逼,性能各方面都不如直接用js来写。。。。
因为不允许使用rust涉及系统的api,所以基本只是换了个皮,核心各种都是js的。
各位老哥引以为戒,不要为了装B而去写,要从实用出发!
github链接:https://github.com/1714080902120/uniapp-async-pkg-inject
另外搞了个npm包,可以直接引入:uniapp-async-pkg-inject - npm (npmjs.com)
npm install uniapp-async-pkg-inject -D实现#
基于#
要解决的问题#
解决当前uniapp不支持分包异步化的问题(底层原理是转换成小程序代码时pages.json会过滤pages为空的分包)
比如pages.json中:
// ...
"subPackages": [
{
"root": "asyncComp",
"name": "asyncComp",
"pages": []
}
// ...
]
// ...最终是会被过滤掉的
我们要解决的就是被过滤掉的问题
原理#
原理很简单,最终uniapp的代码都会转换成符合微信小程序要求的代码,所以我们可以在uniapp => miniprogram完成之后,我们直接操作pages.json,把我们被过滤掉的给补充回去。
得益于组件并不会被编译进vendor,所以我们可以直接把一堆组件所在的文件夹直接定义成一个包!
当然,只是补充pages.json还是不够的,根据微信官方的要求,如果分包异步化的分包还没加载,那么组件就不能被使用,这个时候如果不用componentPlaceholder的话会直接报错,所以我们还需要给所有的组件的.json修改一波。
比如组件xxx.vue引用了xx组件,我们把这个xx迁移至需要分包异步化:
import xx from '@/asyncComp/xx/xx.vue';转换成微信小程序的代码后,会生成我们熟悉的xxx.json:
{
"navigationStyle": "custom",
"enablePullDownRefresh": true,
"usingComponents": {
// ...
"xx": "/asyncComp/xx/xx",
// ...
},
// ...
}我们还需要给这个xx补充一个未加载前的占位元素,一般可以是view:
"componentPlaceholder": {
"xx": "view"
}那么到这原理就基本解释完了。
依赖#
[package]
name = "rust_uniapp_async_pkg_inject"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
js-sys = "0.3.69"
wasm-bindgen = "0.2.92"
web-sys = { version = "0.3.69", features = ["console"] }
regex = "1.10.4"代码#
代码就不多说了,直接放出来:
use js_sys::{global, Array, ArrayBuffer, Function, JsString, Object, Reflect, JSON};
use regex::Regex;
use std::{collections::VecDeque, env, error::Error, fs, path::PathBuf};
use tools::{
get_js_function, get_nested_property, get_value_from_json, get_value_from_obj, obj_not_exist,
set_property, to_jss,
};
use wasm_bindgen::prelude::*;
use web_sys::console::{log_1, log_2, log_3, log_4, time, time_end};
mod tools;
#[wasm_bindgen(module = "fs")]
extern "C" {
#[wasm_bindgen]
pub fn readFileSync(path: &str, decode: &str) -> JsValue;
#[wasm_bindgen]
pub fn writeFileSync(path: &str, content: &str);
#[wasm_bindgen]
pub fn readdirSync(path: &str) -> Array;
#[wasm_bindgen]
pub fn statSync(path: &str) -> Object;
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen]
pub fn require(name: &str) -> JsValue;
}
fn read_pages_json(path: &str) -> Result<JsValue, JsValue> {
Ok(JSON::parse(&remove_comments(
readFileSync(path, "utf-8")
.as_string()
.ok_or(JsValue::from_str("read pages json fail"))?,
))?)
}
fn get_abs_path(path: &str) -> Result<PathBuf, std::io::Error> {
Ok(env::current_dir()?.join(path))
}
fn write_json_into_file(path: &str, content: JsValue) -> Result<(), Box<dyn Error>> {
let content = JSON::stringify(&content.into())
.expect("stringify json fail")
.as_string()
.ok_or("stringify json fail")?;
writeFileSync(path, &content);
Ok(())
}
fn remove_comments(pages: String) -> String {
let multi_line_comment_re = Regex::new(r"/\*.*?\*/").expect("get multi_line regexp fail");
let single_line_comment_re = Regex::new(r"(?m)^\s*//.*$").expect("get single_line regexp fail");
let pages_no_multi_line = multi_line_comment_re.replace_all(&pages, "");
let pages_no_comments = single_line_comment_re.replace_all(&pages_no_multi_line, "");
pages_no_comments.into()
}
#[wasm_bindgen]
pub fn rewrite_dist_app_json(dist_path: &str, app_json_path: &str) -> Result<Array, JsValue> {
log_1(&to_jss("\n-------------开始重写dist/pages.json-------------"));
time();
let async_packages = match read_pages_json(app_json_path) {
Ok(json) => {
let sub_packages: Array = get_value_from_json(&json, "subPackages")?.into();
sub_packages.filter(&mut |item, _, _| {
let pages: Array = get_value_from_json(&item, "pages")
.unwrap_or_else(|_e| Array::new().into())
.into();
pages.is_undefined()
|| pages.is_null()
|| if pages.is_array() {
pages.length() == 0
} else {
false
}
})
}
Err(e) => return Err(e),
};
let dist_app_json_path = format!("{dist_path}/app.json");
let dist_app_json = require(&dist_app_json_path);
let dist_sub_packages: Array = Reflect::get(&dist_app_json, &to_jss("subPackages"))?.into();
let async_pkg_roots = Array::new();
for pkg in async_packages {
async_pkg_roots.push(&Reflect::get(&pkg, &to_jss("root"))?);
dist_sub_packages.push(&pkg.into());
}
match write_json_into_file(&dist_app_json_path, dist_app_json) {
Ok(_) => {
log_1(&to_jss("\n-------------重写完毕,耗时:"));
time_end();
Ok(async_pkg_roots)
}
Err(e) => Err(JsValue::from(e.to_string())),
}
}
fn write_str_into_file(list: Vec<(&str, &str)>) -> std::io::Result<()> {
for (path, content) in list.into_iter() {
writeFileSync(&path, content)
}
Ok(())
}
#[wasm_bindgen]
pub fn inject_empty_wrapper(path: &str) -> Result<(), JsValue> {
let js = r#"Component({})"#;
let wxml = r#"<view style="display:none;" class="_div"></view>"#;
let json = r#"{ "usingComponents": {}, "component": true }"#;
match write_str_into_file(vec![
(&format!("{path}/FuEmptyWrapper.js"), js),
(&format!("{path}/FuEmptyWrapper.wxml"), wxml),
(&format!("{path}/FuEmptyWrapper.json"), json),
]) {
Ok(_) => Ok(()),
Err(e) => Err(JsValue::from(e.to_string())),
}
}
fn inject_placeholder(path: &str, reg: &Regex, json: JsValue) -> Result<(), JsValue> {
let json = if json.is_undefined() { require(path) } else { json };
// log_1(&json);
let using_components: Object = get_value_from_json(&json, "usingComponents")?.into();
if obj_not_exist(&using_components) {
return Ok(());
}
let mut component_placeholder = Reflect::get(&json, &to_jss("componentPlaceholder"))?.into();
for component_name in Reflect::own_keys(&using_components)? {
let component_path_str = Reflect::get(&using_components, &component_name)?
.as_string()
.ok_or(JsValue::from_str("get compoent path str fail"))?;
if reg.is_match(&component_path_str) {
if obj_not_exist(&component_placeholder) {
component_placeholder = Object::new();
Reflect::set(
&json,
&to_jss("componentPlaceholder"),
&component_placeholder,
)?;
}
if !&component_placeholder.has_own_property(&component_name) {
// 引用状态还在的
Reflect::set(
&component_placeholder,
&component_name,
&to_jss("fu-empty-wrapper").into(),
)?;
}
}
}
if obj_not_exist(&component_placeholder) {
return Ok(());
}
if !using_components.has_own_property(&to_jss("fu-empty-wrapper").into()) {
set_property(
&using_components,
"fu-empty-wrapper",
&to_jss("/FuEmptyWrapper"),
)?;
}
match write_json_into_file(&path, json) {
Ok(_) => Ok(()),
Err(e) => Err(JsValue::from(e.to_string())),
}
}
fn check_if_is_directory(path: &str) -> Result<bool, JsValue> {
let stat = statSync(path);
let is_directory = get_js_function("isDirectory", &stat)?;
Ok(is_directory.call0(&stat)?.is_truthy())
}
fn traverse(
base_path: &str,
async_pkg_roots: Vec<String>,
ignore_keywords: Vec<String>,
) -> Result<(), Box<dyn Error>> {
let async_root_regexp = Regex::new(&async_pkg_roots.join("|"))?;
let ignore_regexp = Regex::new(&ignore_keywords.join("|"))?;
// 这里还是采用遍历 + 广度优先的方案,因为ignore_path的指针递归每层都在复制,避免内存膨胀
// 时间复杂度都是O(n),每个节点只过一遍
let mut queue = VecDeque::new();
queue.push_back(base_path.to_string());
while !queue.is_empty() {
let len = queue.len();
for _i in 0..len {
let path = &queue.pop_front().expect("get path from queue fail");
if ignore_regexp.is_match(path) {
log_1(&to_jss(&format!("-------------跳过:{}", path)));
continue;
}
for file_name in readdirSync(path) {
let name: String = file_name.as_string().unwrap();
let file_path = format!("{}/{}", path, name);
if name.ends_with(".json") {
match inject_placeholder(&file_path, &async_root_regexp, JsValue::undefined()) {
Err(e) => log_1(&e),
_ => {}
}
} else {
match check_if_is_directory(&file_path) {
Ok(state) if state => queue.push_back(file_path),
Err(e) => log_1(&e),
_ => {}
}
}
}
}
}
Ok(())
}
#[wasm_bindgen]
pub fn traverse_all_components_json(
path: &str,
async_pkg_roots: Vec<String>,
ignore_keywords: Vec<String>,
) -> Result<(), JsValue> {
log_1(&to_jss("-------------开始遍历组件json文件-------------"));
time();
match traverse(path, async_pkg_roots, ignore_keywords) {
Err(e) => return Err(JsValue::from(e.to_string())),
_ => {}
};
log_1(&to_jss("-------------遍历结束,耗时:"));
time_end();
Ok(())
}
/// 处理组件的json文件
/// 这里还是准备只处理change的json,全处理太浪费资源了
#[wasm_bindgen]
pub fn traverse_some_components_json(
dist_path: &str,
files: Vec<Array>,
async_pkg_roots: Vec<String>,
ignore_keywords: Vec<String>,
) -> Result<(), JsValue> {
log_1(&to_jss("-------------开始遍历组件json文件-------------"));
time();
let async_root_regexp = Regex::new(&async_pkg_roots.join("|"))
.expect("get regexp fail when traverse some components json");
let ignore_regexp = Regex::new(&ignore_keywords.join("|"))
.expect("get regexp fail when traverse some components json");
for el in files {
let file_path = el.get(0).as_string().unwrap();
let json = el.get(1);
if ignore_regexp.is_match(&file_path) {
log_1(&to_jss(&format!("-------------跳过:{}", file_path)));
continue;
}
inject_placeholder(&format!("{dist_path}/{file_path}"), &async_root_regexp, json)?;
}
log_1(&to_jss("-------------遍历结束,耗时:"));
time_end();
Ok(())
}
简单的说下:
#[wasm_bindgen(module = "fs")]:用来指定当前要使用的js module是哪个,这里指定fs,然后我们就可以直接引入fs的方法,比如readFileSync等rewrite_dist_app_json:重写编译生成的app.json,插入我们被过滤掉的分包inject_empty_wrapper:插入占位元素,这里我搞成一个组件,也可以直接用view,都行,但一定要有占位元素,不然会报错。traverse_all_components_json:遍历整个mp-weixin文件夹里的json文件,然后插入占位组件traverse_some_components_json:同上,但是只处理传入的参数这部分,为什么要单独再写一个,等会你就知道了
打包#
wasm-pack build -t nodejs --release --out-dir dist --out-name index不多说
使用#
实现只是完成了第一步,还需要知道怎么使用
这里我们直接在项目中引入:
npm install uniapp-async-pkg-inject -D然后创建一个webpack插件:
const path = require('path');
const { rewrite_dist_app_json, inject_empty_wrapper, traverse_all_components_json, traverse_some_components_json } = require('uniapp-async-pkg-inject/index');
class AutoInjectFuviewPackageDev {
constructor() {
this.isInject = false;
}
apply(compiler) {
const base_path = process.cwd();
const mode = process.env.NODE_ENV === 'production' ? 'build' : 'dev';
const distPath = path.join(base_path, `/dist/${mode}/mp-weixin`);
const appJsonPath = path.join(base_path, `/src/pages.json`);
// 需要忽略的路径,注意执行时会把这些装换成一个全局匹配的正则,所以你需要确保路径不会被误伤
const ignoreKeywords = ["app.json", "ext.json", "static", "node-modules", "uni_modules", "common"];
// 呃,这个属实是语言不同的无奈,用于存储需要异步化分包的名字
let asyncPkgRoots = [];
// 二次编译时拿到差量
const needed = []
// 是否需要重写app.json
let containAppJson = false;
compiler.hooks.assetEmitted.tap("collect change data", (fileName, content) => {
if (this.isInject) {
if (fileName.endsWith(".json")) {
// 如果有改动过`pages.json`,这里就需要重写一次`pages.json`
// 理论上不需要重新处理整个包的json,因为如果改pages.json,那一定会触发对应目标页面的json改变
// 不然就是uniapp的bug了,所以这里直接重写app.json并且拿差量的处理即可
if (fileName.includes('pages.json')) {
containAppJson = true
}
try {
needed.push([fileName, JSON.parse(content.toString())]);
} catch (error) {
console.error('someting went wrong when parse content')
}
}
}
})
compiler.hooks.done.tap("inject async pkg after emit assets", (compilation, callback) => {
if (!this.isInject) {
this.isInject = true;
// 重写`app.json`
asyncPkgRoots = rewrite_dist_app_json(distPath, appJsonPath);
// 注入占位组件(这一步也可以不要,不过你要自行调整逻辑,让占位变成你想要的)
inject_empty_wrapper(distPath);
// 遍历`dist/dev/mp-weixin`下非ignore的所有组件json
traverse_all_components_json(distPath, asyncPkgRoots, ignoreKeywords);
}
// 除了第一次之外,剩下的直接处理差量的即可
if (needed.length > 0) {
if (containAppJson) {
asyncPkgRoots = rewrite_dist_app_json(distPath, appJsonPath);
}
// 只处理差量的文件
traverse_some_components_json(distPath, needed, asyncPkgRoots, ignoreKeywords);
// needed = []
needed.splice(0, needed.length);
} else {
console.log("------------本次改动不包含组件引用改动------------")
}
containAppJson = false;
callback && callback()
})
}
}- 我们在
hooks.done阶段执行我们的方法 - 我们在
hooks.assetEmitted注册了回调,获取此次重新编译的assets或者叫chunk,然后给done时使用。
这么做之后 只需要重写app.json文件一次(后续改动涉及app.json还是需要重写),并且除了第一次,后面都是差量调整,耗时算下来会少很多。
最后引入webpack插件即可,比如在vue.config.js中:
plugins.push(
process.env.NODE_ENV === 'production' ?
new AutoInjectFuviewPackageProd() :
new AutoInjectFuviewPackageDev()
);那么到这就完成啦~
总结#
其实主要逻辑还是那位老哥的方案,但是那位老哥的代码只是针对于生产环境的,对开发环境不太友好,所以才有了这次的实现。
最后强调一次:从实用出发!
