渲染引擎: 绘制UI页面

129 阅读6分钟

本节概述

上一小节我们已经完成了对逻辑代码的执行工作,逻辑线程加载小程序 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.jsstyle.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