上文说到, 小程序架构中存在两个关键问题, 不解决则小程序项目就无从谈起. 在这篇文章中, 我们会尝试解决这两个问题.
如果 js 只在逻辑进程中运行, 不能和外部通信, 那么它既不能使用原生能力, 也不能在渲染进程中构建出实际页面, 小程序架构也就无从谈起. 因此, 小程序基础库首先需要解决的就是 js 如何和 Native/渲染进程通信问题.
所谓逻辑引擎, 实际上是 V8/jsCore 的实例. Native 首先实例化 V8 对象, 然后执行小程序 js 文件. 我们需要的是, 如何在执行 js 文件的过程中, 实现和 V8 之间的双向通信.
方法实际上比较简单.
由于 V8 是 Native 构建出的一个对象实例, 所以 Native 可以直接在 V8 中执行方法. 此时, JS 方需要做的, 就是启动后注册全局函数V8CallJs
供 Native 调用. Native 通过参数告知 JS 实际需要传递的信息.
JS 调用 Native 相对比较繁琐, 需要 Native 先在 V8 中注册全局函数JsCallNative
供 js 调用, 在 java 中也要创建类并实现JavaCallback
接口. 当 js 调用JsCallNative
时, 会暂停 V8 引擎的运行并将控制权交给 Native. 待函数完成后才会恢复 V8 中 js 的执行. 在实际实现中, 为了避免暂停 V8 引擎导致界面失去响应, 基础库一般会把业务方的原生调用做成回调函数的形式. Native 获知 js 所要调用的函数名和相关参数后立刻返回, 后续通过V8CallJs
通知具体执行结果, 然后基础库再执行回调函数, 将结果转发给业务方. 这就是为什么微信小程序库中那么多 callback 回调的原因.
问: 15 年推出的微信小程序里都是 callback 可以理解, 但为什么后期出现的支付宝小程序/京东小程序里也是 callback, 而不是更加现代的 Promise?
答: 后续的开发者当然想优化 API 的设计, 用 Promise 替代难用难维护的 callback. 但问题是微信小程序的 API 是目前业内小程序方案的事实标准, 如果 API 参数&返回值和微信不一致, 接入新开发者/使用小程序转码工具接入新应用都会很困难. 然而应用数量是小程序平台的核心 KPI, 所以初期实现时只能以微信为准.
不过好消息是微信也在逐步将 callback 回调改为 Promise. 相信未来我们终有告别 callback 的一天.
Native 调用 js
和 js 调用 Native
的具体实现可以参考 JS-V8 通信方案, 这里重点介绍一下 js 端的实现流程.
js 与 Native 双向通信有两个核心要素
这一条决定了, 跨语言通信期间, 需要传递的信息最好全部编码为字符串格式, 再具体点说, 是 JSON 字符串. 具体信息通过 json 字段进行传递
为了性能当然也可以用二进制方案----只是要做好 debug 难度暴增的准备. 一般来说, 初始阶段快速验证为重, 不建议太追求性能.
所以我们最终的通信协议如下所示
type Type_Protocol = {
/**
* 命令id
*/
id: number;
/**
* 命令类型
*/
type: "JsCallV8" | "V8CallJs" | "V8CallWebview" | "WebviewCallV8";
/**
* 具体调用的API名
*
* 可能是Native向JS提供的API : pickerImg/httpRequest/getLocation
* 也可能是JS向Native提供的API: onProgramHidden/onHomeButtonPress/onProgramHidden
*/
apiName: string;
/**
* json化后的参数列表, 视API具体约定
*/
argvListJson: string; // Json化后的参数列表
};
而通信时序图如下所示
制定完逻辑进程的通信协议, 渲染进程的通信问题就很好处理了. 渲染进程的 webview 也是由 Native 实例化完成, 可以直接复用我们在逻辑进程中设定的通信协议----简单来说, 完全可以把渲染进程中的通信视为 js-bridge 进行处理.
至此, 逻辑进程 <--> Native
双向通信完成, 渲染进程 <--> Native
双向通信完成, 逻辑进程和渲染进程利用 Native 中转也就可以进行通信. 最终通信模型如下
逻辑进程 <--> Native <--> 渲染进程
然后是第二个问题
逻辑层中运行的是正常 js, 渲染层中展示的是实际 dom 元素, js 不能直接在渲染层中进行操作也不能使用 DOM API 函数----那怎么生成最终的 dom 页面节点?
计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决
首先想到的是封装 DOM API 操作指令. 虽然不能直接操作 Dom, 但是逻辑层和渲染层可以互相通信. 那我们可以通过向渲染层发送Dom API函数名 + 对应参数
的形式, 由运行在渲染层上的 webview-render 实际执行这些函数, 从而间接实现调用 DOM API 的效果.
这个思路很好, 比如我们可以在逻辑层中 mock 一个 document 对象, 对业务方暴露document.createElement
方法, 从而在业务方调用该方法的时候把参数原样发送到渲染层完成问题.
方案好是好, 但实际操作中有点麻烦, 能不能简单一点?
这样也可以, 我们可以自定义一套模板语法, 根据模板语法创建实际 dom, 让用户可以不去写document.createElement
.
在模板语法方面, 最简单的方案是html + innerHtml
, 进阶而言是编写模板生成虚拟 dom + 利用 snabbdom(业内的虚拟 Dom 库) 生成 dom 更新指令, 也就是微信小程序目前使用的方案. 但, 订制模板语法 + 构建虚拟 dom 开发成本还是很高, 还能更简单点吗?
当然可以!
第三层思考: 直接使用 React 作为小程序界面展示方案
React16 相对 15 的一个重大变化, 就是将架构模型升级到了 Fiber. 在 Fiber 架构下, React 执行过程如下所示.
React component API <----> Reconciler 调和器 ----> Renderer 渲染器
React component API
对应于业务层代码, 是我们熟悉的 setState/useState 状态控制函数和 compontentDidUpdate/shouldComponentUpdate 生命周期方法.
组件中的状态控制函数(setState/useState)由Reconciler 调和器
实现. 这样当组件创建完成/状态发生改变时, 就可以被Reconciler 调和器
发现, 进而比较虚拟 dom 变动生成更新指令. 然后用实现了HostConfig接口的对象作为中间层, 将虚拟 dom 指令转发给Renderer 渲染器
, 并由Renderer 渲染器
根据虚拟 Dom 指令在对应平台上转换为实际效果.
在 React16 的渲染流程里, 有三个关键点
生成虚拟Dom
/最终页面展示
无关Reconciler 调和器
输出的虚拟 dom 操作指令通过实现了 HostConfig 接口的对象进行转发, 该对象只要求实现约定接口, 对提供者和接口具体实现没有要求Renderer 渲染器
只需要保证将传来的操作指令转译为平台上对应的操作, 对操作方式的具体实现没有要求, 对平台也没有要求那么, 我们是否可以在逻辑进程里实现一个 HostConfig 对象, 在渲染进程上实现一个 webview-render. 然后通过 Native 把 HostConfig 收到的操作指令转发给 webview-render, 从而完成页面的构建呢?
当然可以!
实现方案如下
业务方React代码 <--> React component API <----> Reconciler 调和器 --> HostConfig对象(逻辑进程) --> Native转发操作命令&操作 ----> webview-render((渲染进程))
基本方案给出, 现在只有两个问题:
Reconciler 调和器
监听到的?我们基于同样构建思路的remax@2.15.0为例, 分析类小程序项目中项目的具体启动过程.
首先介绍一下 remax 方案下小程序项目的基本启动模型:
JSX
对象和对应的 pathHostConfig
协议所约定接口的对象, 作为负责实际渲染的容器Container
JSX
对象JSX
对象JSX
对象Container
对象, 和JSX
对象 一起传入由Reconciler
导出的render
方法Reconciler
会将JSX
渲染为虚拟 DomJSX
变动, 不断产生更新指令, 将指令转换为HostConfig
中约定的 Dom 操作, 并调用Container
暴露的操作方法.Container
根据被调用的操作, 创建实际 Dom. 从而生成实际页面Container
并不会创建实际 Dom.Reconciler
传入的 Dom 操作指令.Native
转发给位于渲染层的webview-render
对象webview-render
对象根据操作指令, 在 webview 中构建实际 Dom也就是这个模型
ReactElement对象 -> Render(React-Reconciler) -> Container(HostConfig) -> 转发命令 -> Webview-Render
我们以Remax@2.15.0
和React@16.7.0
为例, 结合实际代码对启动流程进行一次跟踪
小程序启动示例代码如下所示
// 最简小程序模型.
// https://github.dev/remaxjs/remax/blob/v2.15.0/packages/remax-runtime/src/__tests__/index.test.tsx#L53
import Container from "@remax/remax-runtime/Container";
import render from "@remax/remax-runtime/render";
const MiniProgramPage = () => <view classname="foo">hello</View>;
const container = new Container();
render(<miniprogrampage <="" span="">>, container);
在这段代码中, 我们完成了以下工作:
Container
, 用于在 js-core 中模拟 Dom 功能, 接收并缓存后续ReactReconciler
传过来的 Dom 指令jsx对象
和Container
传给 render, 进入渲染逻辑.值得一提的是, 整个小程序启动进程只有这三行代码, render
函数执行完毕启动进程即宣告结束. 后续 render 中的 react-reconciler 会接管jsx对象
的 setState 方法, 从而可以接管组件中的所有变动, 进而和旧 jsx 对象进行比较, 计算虚拟 Dom 变更情况, 生成实际 Dom 操作指令, 然后再根据 HostConfig 协议调用 Container 对象上暴露的方法...
HostConfig 协议和 Container 对象的实现我们放在下篇文章, 这篇文章我们只搞清楚两件事:
先看下 render 函数的实现
// 位于 https://github.dev/remaxjs/remax/blob/v2.15.0/packages/remax-runtime/src/render.ts
import * as React from "react";
import ReactReconciler from "react-reconciler";
import hostConfig from "./hostConfig";
import Container from "./Container";
import AppContainer from "./AppContainer";
export const ReactReconcilerInst = ReactReconciler(hostConfig as any);
if (process.env.NODE_ENV === "development") {
ReactReconcilerInst.injectIntoDevTools({
bundleType: 1,
version: "16.13.1",
rendererPackageName: "remax",
});
}
function getPublicRootInstance(container: ReactReconciler.FiberRoot) {
const containerFiber = container.current;
if (!containerFiber.child) {
return null;
}
return containerFiber.child.stateNode;
}
export default function render(
rootElement: React.ReactElement | null,
container: Container | AppContainer
) {
// Create a root Container if it doesnt exist
if (!container._rootContainer) {
container._rootContainer = ReactReconcilerInst.createContainer(
container,
0,
false,
null
);
}
ReactReconcilerInst.updateContainer(
rootElement,
container._rootContainer,
null,
() => {
// ignore
}
);
return getPublicRootInstance(container._rootContainer);
}
可以看到, render 函数实际是对ReactReconciler
的封装. 整个实现可以分为三步:
ReactReconcilerInst
对象, 后续ReactReconciler
会根据 HostConfig 提供的 API 生成 Dom 操作指令, 然后按照指令调用container
上的接口ReactReconcilerInst.createContainer
方法将container
对象包装为 Fiber 节点ReactReconcilerInst.updateContainer
方法获取待渲染的 JSX
对象至此, 整个流程执行完毕. 为ReactReconciler
输入HostConfig
&container
&JSX
, ReactReconciler
会启动对JSX
的渲染, 并根据JSX
对象的变动计算虚拟 Dom 的变更, 生成实际 Dom 更新指令并根据 HostConfig 配置调用 container 上的方法.
但这里存在一个问题了, JSX
只是一个普普通通的 React.Component
对象, 状态变更调用的也是内部的 setState 方法, ReactReconciler
是怎么知到JSX
的变动状态并计算虚拟 Dom 变更的呢?
实际情况是ReactReconciler
在updateContainer
方法中, 替换了JSX
对象中 setState 方法的实现. 因此可以获知JSX
的所有变动情况, 并根据需要调用JSX
的生命周期钩子, 获取状态更新后的 render 结果.
不过说归说, talk is cheap show me your code. 接下来还是要依次看下 createContainer 和 updateContainer 的实现, 这里要涉及 react 的源码, 我们以react@16.7.0为例
首先是 createContainer
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react-reconciler/src/ReactFiberReconciler.js#L274
export function createContainer(
containerInfo: Container,
isConcurrent: boolean,
hydrate: boolean
): OpaqueRoot {
// 如果追下去的话会发现真的只初始化了一个FiberRoot, 其他啥都没干.
return createFiberRoot(containerInfo, isConcurrent, hydrate);
}
可以看到, 初始化容器只是简单创建了一个 Fiber 节点并返回, 本身没有多余操作
然后看看 updateContainer 的实现
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react-reconciler/src/ReactFiberReconciler.js#L282
export function updateContainer(
element: ReactNodeList,
container: OpaqueRoot,
parentComponent: ?React$Component<any, any>,
callback: ?Function
): ExpirationTime {
const current = container.current;
const currentTime = requestCurrentTime();
const expirationTime = computeExpirationForFiber(currentTime, current);
return updateContainerAtExpirationTime(
element,
container,
parentComponent,
expirationTime,
callback
);
}
updateContainer 主要工作就是将jsx对象
和container
传给updateContainerAtExpirationTime
, 并注册更新任务. 如果继续跟进的话, 可以看到以下调用链
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react-reconciler/src/ReactFiberReconciler.js#L161
updateContainerAtExpirationTime{
// ...省略其余代码
return scheduleRootUpdate(current, element, expirationTime, callback);
}
=>
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react-reconciler/src/ReactFiberReconciler.js#L161
export function updateContainerAtExpirationTime(
element: ReactNodeList,
container: OpaqueRoot,
parentComponent: ?React$Component<any, any>,
expirationTime: ExpirationTime,
callback: ?Function
) {
// ...省略其余代码
return scheduleRootUpdate(current, element, expirationTime, callback);
}
=>
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react-reconciler/src/ReactFiberReconciler.js#114
function scheduleRootUpdate(
current: Fiber,
element: ReactNodeList,
expirationTime: ExpirationTime,
callback: ?Function
) {
// ...省略其他代码
scheduleWork(current, expirationTime);
}
=>
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react-reconciler/src/ReactFiberScheduler.js#L1788
function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) {
requestWork(root, rootExpirationTime);
// ...省略其他代码
}
requestWork
对应的是注册组件更新任务代码, 如果继续跟下去的话, 会依次看到下边的调用链, 一直到beginWork
requestWork
=>performWorkOnRoot
=>renderRoot
=>workLoop
=> performUnitOfWork
=> beginWork
看下beginWork
的代码
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react-reconciler/src/ReactFiberBeginWork.js#L1673
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderExpirationTime: ExpirationTime
): Fiber | null {
// ...省略其他代码
switch (workInProgress.tag) {
case FunctionComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderExpirationTime
);
}
case ClassComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderExpirationTime
);
}
}
// ...省略其他代码
}
对于函数组件, ReactReconciler 调用的是updateFunctionComponent
函数, 对于类组件, ReactReconciler 调用的是updateClassComponent
至此, render 函数的原理讲解完毕. 接下来是那个核心问题: ReactReconciler
是怎么拿到JSX
的状态变更的.
先从类组件开始.
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react-reconciler/src/ReactFiberBeginWork.js#L531
function updateClassComponent(
current: Fiber | null,
workInProgress: Fiber,
Component: any,
nextProps,
renderExpirationTime: ExpirationTime
) {
// ...省略其他代码
constructClassInstance(
workInProgress,
Component,
nextProps,
renderExpirationTime
);
}
updateClassComponent
中我们需要关注的是constructClassInstance
, 这是类组件实例化的方法
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react-reconciler/src/ReactFiberClassComponent.js#513
function constructClassInstance(
workInProgress: Fiber,
ctor: any,
props: any,
renderExpirationTime: ExpirationTime
): any {
// ...省略其他代码
adoptClassInstance(workInProgress, instance);
}
这里的关键是adoptClassInstance
, 在这个函数中, 组件实例的updater
被设置为了classComponentUpdater
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react-reconciler/src/ReactFiberClassComponent.js#L503
function adoptClassInstance(workInProgress: Fiber, instance: any): void {
// 关键代码
instance.updater = classComponentUpdater;
// ...省略其他代码
}
而这个classComponentUpdater
, 其代码如下
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react-reconciler/src/ReactFiberClassComponent.js#L188
const classComponentUpdater = {
isMounted,
enqueueSetState(inst, payload, callback) {
const fiber = getInstance(inst);
const currentTime = requestCurrentTime();
const expirationTime = computeExpirationForFiber(currentTime, fiber);
const update = createUpdate(expirationTime);
update.payload = payload;
if (callback !== undefined && callback !== null) {
if (__DEV__) {
warnOnInvalidCallback(callback, "setState");
}
update.callback = callback;
}
flushPassiveEffects();
enqueueUpdate(fiber, update);
scheduleWork(fiber, expirationTime);
},
// ...省略其他代码
};
由于classComponentUpdater
由ReactReconciler
提供, 所以对classComponentUpdater
自然可以被ReactReconciler
捕获到.
但为什么将组件实例的updater
设置成classComponentUpdater
就会被捕获呢? 搂一眼React.Component
的源码
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react/src/ReactBaseClasses.js#L58
Component.prototype.setState = function (partialState, callback) {
invariant(
typeof partialState === "object" ||
typeof partialState === "function" ||
partialState == null,
"setState(...): takes an object of state variables to update or a " +
"function which returns an object of state variables."
);
this.updater.enqueueSetState(this, partialState, callback, "setState");
};
显然, Component
中的 setState 实际上调用的就是 updater 上的enqueueSetState
方法. 而由于 updater 本身已经被替换为了ReactReconciler
自身的实现, 所以自然可以捕获到类组件上的所有数据变更.
问题得解
接着看下一项, ReactReconciler
对函数组件中 useState 的接管实现
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react/src/ReactHooks.js#L54
export function useState<S>(initialState: (() => S) | S) {
const dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
useState 位于ReactHooks.js
文件, 实际调用的是ReactCurrentOwner.currentDispatcher
上提供的 useState 方法
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react/src/ReactHooks.js#L14
import ReactCurrentOwner from "./ReactCurrentOwner";
function resolveDispatcher() {
const dispatcher = ReactCurrentOwner.currentDispatcher;
invariant(
dispatcher !== null,
"Hooks can only be called inside the body of a function component."
);
return dispatcher;
}
而resolveDispatcher
返回的又是ReactCurrentOwner.currentDispatcher
对象. 这个ReactCurrentOwner
看起来位于packages/react/src/ReactCurrentOwner.js
, 但点进去会发现里边只有一个普通对象
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react/src/ReactCurrentOwner.js#L1
import type {Fiber} from 'react-reconciler/src/ReactFiber';
import typeof {Dispatcher} from 'react-reconciler/src/ReactFiberDispatcher';
/**
* Keeps track of the current owner.
*
* The current owner is the component who should own any components that are
* currently being constructed.
*/
const ReactCurrentOwner = {
/**
* @internal
* @type {ReactComponent}
*/
current: (null: null | Fiber),
currentDispatcher: (null: null | Dispatcher),
}
export default ReactCurrentOwner;
所以react/src/ReactCurrentOwner.js
显然不是ReactCurrentOwner
实际的提供者. 如果返回beginWork
, 看ReactReconciler
提供ReactCurrentOwner
的方式时我们会看到
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react-reconciler/src/ReactFiberBeginWork.js#L47
// ...省略其他代码
import ReactSharedInternals from "shared/ReactSharedInternals";
// ...省略其他代码
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
// ...省略其他代码
function updateFunctionComponent(
current,
workInProgress,
Component,
nextProps: any,
renderExpirationTime
) {
// ...省略其他代码
}
ReactReconciler
也提供了一个ReactCurrentOwner
, 如果继续往后跟, 可以看到他在workLoop
中替换了ReactCurrentOwner.currentDispatcher
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react-reconciler/src/ReactFiberScheduler.js#29
import ReactSharedInternals from "shared/ReactSharedInternals";
// ...省略其他代码
const { ReactCurrentOwner } = ReactSharedInternals;
// 位于 https://github.com/facebook/react/blob/v16.7.0/packages/react-reconciler/src/ReactFiberScheduler.js#1187
function workLoop(isYieldy) {
// ...省略其他代码
if (enableHooks) {
ReactCurrentOwner.currentDispatcher = Dispatcher;
} else {
ReactCurrentOwner.currentDispatcher = DispatcherWithoutHooks;
}
}
但问题是, ReactReconciler
引入的是shared/ReactSharedInternals
, react 中引用的却是react/src/ReactCurrentOwner.js
, 这是怎么做到的?
来看这段代码
// 位于 https://github.com/facebook/react/blob/v16.7.0/scripts/rollup/forks.js#L48
// Without this fork, importing `shared/ReactSharedInternals` inside
// the `react` package itself would not work due to a cyclical dependency.
'shared/ReactSharedInternals': (bundleType, entry, dependencies) => {
if (entry === 'react') {
return 'react/src/ReactSharedInternals';
}
if (dependencies.indexOf('react') === -1) {
// React internals are unavailable if we can't reference the package.
// We return an error because we only want to throw if this module gets used.
return new Error(
'Cannot use a module that depends on ReactSharedInternals ' +
'from "' +
entry +
'" because it does not declare "react" in the package ' +
'dependencies or peerDependencies. For example, this can happen if you use ' +
'warning() instead of warningWithoutStack() in a package that does not ' +
'depend on React.'
);
}
return null;
},
显然, 答案是 rollup.
react 在使用 rollup 构建时, 通过定制编译脚本, 在输出将shared/ReactSharedInternals
映射为了react/src/ReactSharedInternals
, 从而实现对ReactCurrentOwner
变量的替换, 进而将 useState 的实际提供者替换为ReactReconciler
, 实现了对 useState 的控制
而我们对ReactReconciler
接管函数组件useState
的过程, 也可以宣告结束.
搞定了ReactReconciler
的秘密, 在接下来的文章里, 我们就可以放心的研究 HostConfig 和 Container 的设计和实现了
通过之前的文章我们知道, Fiber 架构下的 React 分为三层, 分别是对外的React Component API
, 也就是我们平常写的 JSX
, 和监控JSX
变动, 根据对应虚拟 Dom 结构变更生成界面操作指令的React-Reconciler
和将界面操作指令转化为对应平台实现的Renderer
渲染器.
React component API <----> Reconciler 调和器 ----> Renderer 渲染器
Reconciler
通过接管 useState
/setState
的实现获取 JSX
对象的变动情况, 并根据变动调用 JSX 对象的生命周期钩子和计算界面更新指令. 但具体实现时, Reconciler
会面临这样一个问题: 我怎么知道当前的 Renderer 渲染器支持哪些指令?
答案当然是在初始化Reconciler
时, 就要告诉Reconciler
当前渲染器支持的指令列表, 而这份列表, 就叫做HostConfig
.
对于 HostConfig, Reconciler
规定了两类 API, 分别是必须接口和可选接口.按 React 项目组的说法, 这些接口目前还不稳定所以并没有公开介绍. 但实际上, 这个功能已经可以满足日常使用了(要不怎么会有 Remax 项目&一众小程序项目). react 项目组给出了HostConfig 的示例, 这里贴一下 remax 中 hostConfig 的部分内容
// 位于 https://github.dev/remaxjs/remax/blob/v2.15.0/packages/remax-runtime/src/hostConfig/index.ts
import * as scheduler from "scheduler";
import { REMAX_METHOD, TYPE_TEXT } from "../constants";
import { generate } from "../instanceId";
import VNode from "../VNode";
import Container from "../Container";
import { createCallbackProxy } from "../SyntheticEvent/createCallbackProxy";
import diffProperties from "./diffProperties";
// ...省略其余代码
export default {
now,
// ...省略其余代码
// 创建dom节点
createInstance(type: string, newProps: any, container: Container) {
const id = generate();
const node = new VNode({
id,
type: DOM_TAG_MAP[type] ?? type,
props: {},
container,
});
node.props = processProps(newProps, node, id);
return node;
},
// 创建文本节点
createTextInstance(text: string, container: Container) {
const id = generate();
const node = new VNode({
id,
type: TYPE_TEXT,
props: null,
container,
});
node.text = text;
return node;
},
// ...省略其余代码
// Reconciler更新周期执行完毕后, 会调用该接口, 通知渲染器可以进行实际渲染
// 在小程序代码中用于作为向webview发送更新指令的标记
resetAfterCommit: (container: Container) => {
container.applyUpdate();
},
};
Reconciler
会根据虚拟 Dom 变动情况, 调用HostConfig
中提供的接口, 这些调用方法和参数汇合到一起, 就是界面更新指令. 而对HostConfig
接口的调用又会被转发给Container
, 由Container
对象维护updateQueue
数组, 记录操作执行过程.
// 位于 https://github.dev/remaxjs/remax/blob/v2.15.0/packages/remax-runtime/src/Container.ts
export default class Container {
// ...省略其余代码
updateQueue: Array = [];
// ...省略其余代码
requestUpdate(update: SpliceUpdate | SetUpdate) {
this.updateQueue.push(update);
}
applyUpdate() {
if (this.stopUpdate || this.updateQueue.length === 0) {
return;
}
// ...省略其余代码
this.context.$spliceData(
{
[this.normalizeUpdatePath([...update.path, "children"])]: [
update.start,
update.deleteCount,
...update.items,
],
},
callback
);
// ...省略其余代码
this.updateQueue = [];
return;
}
}
当Reconciler
的一个更新周期结束时, 会调用HostConfig
上的resetAfterCommit
函数, 然后被转发给Container
的applyUpdate
方法. Container
收到消息后, 将之前记录下来的界面更新指令 JSON 化为字符串, 通过 Native 转发给 运行在 webview 上的 webview-render
对象, webview-render 收到更新指令后, 根据指令操作实际 Dom, 界面构建完成.
界面的更新指令则由两种类型实现. SpliceUpdate
对应于节点变动, 前端收到后直接删除旧 Dom, 创建新 Dom. 但这样会出现问题. 例如, 对于元素, 当 value 发生改变时, 如果直接删除重建 input 元素, 会导致输入光标丢失. 因此出现了SetUpdate
指令, 对于该指令, 只更新 Dom 属性, 不重建 Dom.
// 界面更新指令类型定义
// 位于 https://github.dev/remaxjs/remax/blob/v2.15.0/packages/remax-runtime/src/Container.ts#L8
interface SpliceUpdate {
path: string[];
start: number;
id: number;
deleteCount: number;
items: RawNode[];
children?: RawNode[];
type: "splice";
node: VNode;
}
interface SetUpdate {
path: string[];
name: string;
value: any;
type: "set";
node: VNode;
}
// 发送到webview-render端的VNode数据结构
// 位于 https://github.dev/remaxjs/remax/blob/v2.15.0/packages/remax-runtime/src/VNode.ts#L6
export interface RawNode {
id: number;
type: string;
props?: any;
nodes?: { [key: number]: RawNode };
children?: Array<rawnode | number>;
text?: string;
}
weview-render 收到指令后会根据 node 中的配置创建 Dom 元素, 并更新到 webview 中. 这个比较好实现, 直接document.createElement
就行. 前端 render 的难点在于: 如何将用户操作时产生的 click/touch/change 事件回传给 js-core 中的 Reconciler?
我们知道, jsx 中绑定的事件处理函数是不能在 json 化之后传递给 webview-render 的, 但是, 不能传递函数, 我们可以传递函数名啊。
在生成 Dom 构建命令时, 我们可以建立一个事件处理函数映射表, 函数名命名规范为${事件名}_${递增计数器}_handler
. 在 webview 中则用 addEventListener 为对应 dom 节点绑定事件处理函数. 当事件发生时, 把 event 对象中的数据和需要调用的函数名通过 Native 传回 js-core 引擎, 然后在 js-core 中调用对应的实际函数, 触发组件状态变更, 组件重新渲染.
至此, 小程序运行流程形成闭环.
通过上下两篇文章, 我们了解了小程序项目价值, 梳理了开发路线图, 解决了小程序开发过程中最为核心的数据传递和跨进程 Dom 交互问题. 但这并不意味着小程序任务的圆满结束. 事实上, 正如小程序业务流程与开发路线图
中分析的那样, 后续的小程序基础库/IDE/后台/组件库更是小程序项目中所面临的难点。
不过, 这一系列的文章已经写得太长, 有必要先收束一下. 至于小程序项目中面临的其他问题该怎么解决嘛。
欲知后事如何, 请待下回分解。