本节概述
上一小节我们已经完成了对逻辑代码的执行工作,逻辑线程加载小程序 logic 代码,并在初始化好的引擎环境内运行,完成对应模块实例的创建和管理,同时启动了对 native bridge 层的消息监听。
本小节开始我们要对UI线程的渲染逻辑进行开发,在启动小程序的过程中,UI线程侧也是需要经过: 全局环境准备 -> 页面模块创建 -> 监听native bridge消息 等步骤,其实流程和逻辑线程还是比较相似的。
在开始实现 UI 线程之前,我们还是和上节一样先模拟一份小程序页面编译后的js代码,来帮助我们理解实现的代码:
// view.js
// 页面view的代码和逻辑线程一样,也是被编译成一个模块,模块会调用全局的 Page 方法完成页面模块的创建
// 页面的渲染主要依赖 render 函数进行,这里我们先通过手动返回 html 的形式进行,后续会使用vue编译生成vue组件
modDefine('pages/home/index', function (require, module, exports) {
Page({
path: 'pages/home/index',
usingComponents: {},
render: (data) => {
return `<div class="container">
<h1>${data.text}</h1>
<div class="num">${data.number}</div>
<button onclick="triggerEvent('tapHandler')">increment</button>
</div>`
}
})
});
环境准备
通过上文我们可以看到,view 页面代码实际也是会被处理为一个个的模块然后调用全局环境的api进行模块的初始化;在上一节中我们已经在 logic 层中完成了 amd 模块管理相关的api的封装,这里我们需要服用这块的逻辑。
我们现在来创建一个 packages/shared 包来承载通用的一些模块,比如 amd 模块管理模块等
在这个公共模块下添加 amd 模块管理 逻辑
/**
* 模块加载器,用于实现小程序逻辑代码的 require 加载逻辑
*
* 这里小程序的js文件都会被包装成一个个 modDefine 调用,被注册成一个模块
*/
const defineCache: Record<string, any> = {}; // 缓存已定义的模块
const requireCache: Record<string, any> = {}; // 缓存已加载的模块
const loadingModules: Record<string, any> = {};
// 模块定义函数
export function modDefine(id: string, factory: any) {
if (!defineCache[id]) {
const modules = {
id: id,
dependencies: [],
factory: factory
};
defineCache[id] = modules;
}
}
// 模块加载函数
export function modRequire(id: string) {
if (loadingModules[id]) {
return {}; // 直接返回,尽管它可能还没有完全被加载
}
if (!requireCache[id]) {
const mod = defineCache[id];
if (!mod) throw new Error("No module defined with id " + id);
const modules = {
exports: {}
};
loadingModules[id] = true;
const factoryArgs = [modRequire, modules.exports, modules];
mod.factory.apply(null, factoryArgs);
requireCache[id] = modules.exports;
delete loadingModules[id];
}
return requireCache[id].exports;
}
这些通用的逻辑模块统一在入口文件进行导出即可,现在我们把这个shared模块编译成一个lib包,提供给别的模块使用:
// vite.config.ts
import { defineConfig } from 'vite';
import path from 'path';
import dts from 'vite-plugin-dts'
export default defineConfig({
// 编译类型声明文件
plugins: [dts({
outDir: path.resolve(__dirname, 'types'),
})],
build: {
outDir: path.resolve(__dirname, 'dist'),
lib: {
entry: path.resolve(__dirname, './src/index.ts'),
name: 'shared',
formats: ['es', 'umd'],
fileName: (format) => `shared.${format}.js`
},
sourcemap: true,
},
resolve: {
extensions: ['.js', '.ts'],
alias: {
'@': path.resolve(__dirname, './src')
}
},
});
现在我们就可以在 ui 模块和 logic 模块中导入这个模块使用了(利用 pnpm workspace),按照下面的方式在ui模块中添加好依赖声明后,执行一次 pnpm install 即可
// 在ui线程导入这个模块
{
"dependencies": {
"shared": "workspace:*"
}
}
UI 引擎全局环境
根据上文中模拟的小程序 view.js 页面代码我们可以发现,UI全局环境需要的内容包括:
- modDefngine 模块管理器
- Page 全局页面模块API
除此之外,这里UI线程和native bridge 的通信,我们这里通过直接给window挂载一个 JSBridge 的对象,通过这个对象来直接完成两个环境间的通信;这个对象会分别由UI线程添加 onReceiveNativeMessage api 然后由 bridge 层调用,实现 bridge -> ui 方向的通信;以及由 bridge 侧添加的 onReceiveUIMessage api 后由 UI 层调用,实现 ui -> bridge 方向的通信;
现在我们开始来实现相关的全局环境准备:
页面加载管理器
页面加载管理器主要是为了完成页面相关的资源加载并添加到页面iframe环境中,比如 view.js 和 style.css 等资源的加载和创建,同时针对 view.js 中创建页面模块等支持
我们先来定义一下页面的 PageModule 模块,它主要用于管理页面的配置信息和页面的渲染数据:
export class PageModule {
type: string = 'page';
/**
* Page 模块配置信息, 如:
* Page({
* path: 'pages/home/index',
* usingComponents: {},
* render: () => {}
* })
*/
moduleInfo: UIPageModuleInfo;
/**
* 初始化data
*/
data: Record<string, any> = {};
constructor(moduleInfo: UIPageModuleInfo) {
this.moduleInfo = moduleInfo;
}
setInitialData(data: Record<string, any>) {
this.data = data;
}
}
现在来创建加载器类,它会完成资源代码的加载,以及页面模块的创建工作
class Loader {
staticModules: Record<string, PageModule> = {};
// 加载小程序页面资源
loadResources(opts: LoaderResourceOpts) {
const { appId, pagePath } = opts;
// 拼接模版资源loader路径: 这里都先把加载的资源放在项目的 public 目录下,直接通过当前的服务加载
const viewResourcePath = `http://localhost:1420/${appId}/view.js`;
const styleResourcePath = `http://localhost:1420/${appId}/style.css`;
return Promise.all([
this.loadStyleFile(styleResourcePath),
this.loadScriptFile(viewResourcePath)
]).then(() => {
window.modRequire(pagePath);
});
}
loadStyleFile(path: string) {
return new Promise<void>((resolve) => {
const link = document.createElement('link');
link.rel = "stylesheet";
link.href = path;
link.onload = () => resolve();
document.head.appendChild(link);
})
}
loadScriptFile(path: string) {
return new Promise<void>((resolve) => {
const script = document.createElement('script');
script.src = path;
script.onload = () => resolve();
document.body.appendChild(script);
})
}
// 创建小程序页面 PageModule 模块
createPageModule(moduleInfo: UIPageModuleInfo) {
const pageModule = new PageModule(moduleInfo);
const { path } = moduleInfo;
this.staticModules[path] = pageModule;
}
// 设置渲染数据
setInitialData(initialData: Record<string, any>) {
for (const [path, data] of Object.entries(initialData)) {
const pageModule = this.staticModules[path];
if (!pageModule) {
continue;
}
pageModule.setInitialData(data);
}
}
// 获取指定路径下的module
getModuleByPath(path: string) {
return this.staticModules[path];
}
}
初始化全局环境
基础准备完成后,我们只需要在全局 window 对象上注入对应的模块能力即可:
import loader from '@/loader';
import { modDefine, modRequire } from 'shared';
class GlobalApi {
init() {
window.modDefine = modDefine;
window.modRequire = modRequire;
window.Page = (moduleInfo: UIPageModuleInfo) => {
loader.createPageModule(moduleInfo);
}
}
}
同时我们给全局 window 对象上添加一个 JSBridge 对象用于后续通信相关的实现
// 全局通信bridge对象
(window as any).JSBridge = {};
UI 渲染管理器
UI 线程的核心工作就是根据小程序的页面信息,结合逻辑线程的数据来绘制页面,同时将用户触发的事件通过 bridge 通知给逻辑线程执行相关的处理函数.
根据上文中模拟的页面模块代码,这里的渲染主要是调用页面模块的 render函数,传入 data 数据,将获取到的 html 结构挂载到页面即可,同时全局添加一个 triggerEvent 方法,用于将页面的事件调用发送给bridge层。(这里只是临时的实现方案,后续我们会借助Vue框架来完成页面渲染和事件触发的事情)
/**
* RuntimeManager class
* 渲染线程运行管理器
*
* page: Vue页面实例
* pageId: 页面实例ID
* startRender(): void; 渲染
* updateModule(opts): void; 更新模块数据
*/
import loader from '@/loader';
import type { UIRenderOpts } from '@/types/common';
class RuntimeManager {
page: any = null;
// 对应 bridgeId; 用于后面和bridge层通信
pageId: string = '';
/**
* ui线程的实例映射: 这里我们先使用页面的渲染函数 render 来渲染。后面这里使用 Vue 来渲染页面后,这里会变成Vue实例
*/
uiInstance: Record<string, any> = {};
startRender(opts: UIRenderOpts) {
const { pagePath, bridgeId } = opts;
this.pageId = bridgeId;
// 获取挂载的根节点,这里后面会使用Vue来渲染我们的页面,这里先直接生成HTML内容然后挂上去
const root = document.querySelector('#root') as HTMLElement;
// 加载页面模块信息
const pageModule = loader.getModuleByPath(pagePath);
message.send({
type: 'moduleCreated',
body: {
path: pagePath,
id: this.pageId,
}
});
// 直接先调用render执行,将data传入渲染出页面 html
const pageRender = pageModule.moduleInfo.render;
const html = pageRender(pageModule.data);
root.innerHTML = html;
message.send({
type: 'moduleMounted',
body: {
id: this.pageId,
}
});
// 临时方案,页面实例先存储渲染函数和root根节点,方便后续更新的时候重新渲染挂载html
this.uiInstance[this.pageId] = {
root,
render: pageRender,
};
// 临时添加全局triggerEvent实现事件发送
(window as any).triggerEvent = (methodName: string, ...args: any[]) => {
message.send({
type: 'triggerEvent',
body: {
id: this.pageId,
methodName,
paramsList: args,
}
});
};
}
updateModule(opts) {
const { id, data } = opts;
const { root, render } = this.uiInstance[id];
const html = render(data);
root.innerHTML = html;
}
}
UI 线程监听 Bridge 消息
上文我们已经介绍过,这里UI 层和 bridge 层的通信会借助 JSBridge 对象来完成,这里在开始监听之前,我们先来实现一下 message 消息的通信类,用于管理消息的接收和派发:
/**
* Messgae Class
* ui线程的通信模块
*
* field: 消息类型
* event: 事件对象
*
* - init(): void; 初始化消息类
* - receive(messageType, handler): void 接收原生层消息
* - send(data): void 发送消息到原生层
*/
import mitt, { Emitter } from 'mitt';
import type { IMessage } from '@/types/common';
export class Messgae {
event: Emitter<Record<string, any>>;
constructor() {
this.event = mitt<Record<string, any>>();
this.init();
}
init() {
// bridge 层直接调用这个api来触发事件通知
window.JSBridge.onReceiveNativeMessage = (msg: IMessage) => {
const { type, body } = msg;
this.event.emit(type, body);
}
}
receive(type: string, callback: (data: any) => void) {
this.event.on(type, callback);
}
send(message: IMessage) {
// bridge 侧会赋值这个 Api,ui层直接调用就可以通知给bridge 层
window.JSBridge.onReceiveUIMessage?.(message);
}
}
现在我们针对启动过程相关的通信流程,对 bridge 侧消息进行监听并启动 UI 侧的渲染。
/**
* messageManager class
* ui层消息处理
*
* - message: Message 通信对象
* - init(): void 消息监听注册
*/
import message, { type Messgae } from '@/message';
import loader from '@/loader';
import runtimeManager from '@/runtimeManager';
class messageManager {
message: Messgae;
constructor() {
(window as any).message = message;
this.message = message;
}
init() {
// 1. bridge 层通知 ui 线程加载小程序页面资源
this.message.receive('loadResource', this.loadResource.bind(this));
// 2. 逻辑线程准备好数据之后,发送给ui线程渲染页面
this.message.receive('setInitialData', this.setInitialData.bind(this));
// 3. 逻辑线程调用 `setData` 更新数据后,通知ui线程重新渲染
this.message.receive('updateModule', this.updateModule.bind(this));
}
private loadResource(data) {
const { appId, pagePath } = data;
loader.loadResources({ appId, pagePath }).then(() => {
this.message.send({
type: 'uiResourceLoaded',
body: {}
})
});
}
private setInitialData(data) {
const { bridgeId, pagePath, initialData } = data;
// 初始话数据有了之后就可以开始渲染页面了
loader.setInitialData(initialData)
runtimeManager.startRender({
pagePath,
bridgeId
});
}
updateModule(data) {
runtimeManager.updateModule(data);
}
}
至此我们的UI线程引擎部分代码就基本实现完啦,接下来就要完善 bridge 侧代码,将逻辑线程和ui线程联动起来啦,大家加油~~
本小节代码已同步至github 仓库,可前往查看完整逻辑: mini-wx-app