小程序基础介绍

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

前言

本文会围绕小程序的基础原理进行介绍。主要包括小程序的基础结构、编译、加载、通讯等几个方面。旨在阅读完毕后可以对小程序有一个基本的印象。

一、基础

对于用户来讲,小程序无需下载、用完即走、体验良好。

对于开发者来讲,小程序主要是区别于端内的内嵌纯Web页面。

  • 第一点是开发体验上的区别,首先需要注册账号,下载开发者工具,然后使用小程序定制的DSL和JavaScript来进行开发,在小程序开发者工具上运行小程序来查看小程序的效果,最后提交代码审核通过后进行发布。

👉 微信小程序开发指南

DSL:Domain Specific Language(领域特定语言)

所谓 DSL,是指利用为特定领域(Domain)所专门设计的词汇和语法,简化程序设计过程,提高生产效率的技术,同时也让非编程领域专家的人直接描述逻辑成为可能。DSL 的优点是,可以直接使用其对象领域中的概念,集中描述“想要做到什么”(What)的部分,而不必对“如何做到”(How)进行描述。

摘自:《代码的未来》 — [日] 松本行弘

目的:限定问题边界和控制复杂度,提高编程效率,更加的抽象化。

举例:1. 内部:jQuery。

  1. 外部:JSX、Sass、SQL、WXML、WXSS、WXS。

我们需要使用WXML、WXSS、WXS结合基础组件和事件系统来构建小程序页面。

WXML

<!--wxml 数据绑定 -->
<view> {{message}} </view>

<!--wxml 列表渲染-->
<view wx:for="{{array}}"> {{item}} </view>

<!--wxml 条件判断-->
<view wx:if="{{view == 'WEBVIEW'}}"> WEBVIEW </view>
<view wx:elif="{{view == 'APP'}}"> APP </view>
<view wx:else="{{view == 'MINA'}}"> MINA </view>

<!--wxml 模板-->
<template name="staffName">
  <view>
    FirstName: {{firstName}}, LastName: {{lastName}}
  </view>
</template>

<template is="staffName" data="{{...staffA}}"></template>
<template is="staffName" data="{{...staffB}}"></template>
<template is="staffName" data="{{...staffC}}"></template>

WXSS

.padding {
    padding: 20rpx;
}

WXS 👉 WXS 语法参考 👉 WXS响应事件

WXS(WeiXin Script)是小程序的一套脚本语言,结合 WXML,可以构建出页面的结构。

WXS 与 JavaScript 是不同的语言,有自己的语法,并不和 JavaScript 一致。

特点:

  1. 运行在视图层,不依赖基础库。

  1. 不能直接setData,只能修改组件的class和style,以及一些格式化处理。(可以间接通过callMethod通知逻辑层设置数据)

  1. 可以进行简单的逻辑运算,能力有限。

  1. 支持绑定事件,可以处理一些用户交互事件。

常用场景:

  1. 数据格式处理,如日期、文本等,可以实现过滤器的功能。

  1. 用户频繁交互且需要改变样式的场景下:页面滚动菜单吸顶、拖拽跟随、侧边栏抽屉等。

过滤器

// /pages/tools.wxs

var foo = "five";
var bar = function (d) {
  return `${d} apples`;
}
module.exports = {
  FOO: foo,
  bar: bar,
};
module.exports.msg = "some msg";
<!-- page/index/index.wxml -->
<wxs src="./../tools.wxs" module="tools" />
<view> {{tools.msg}} </view>
<view> {{tools.bar(tools.FOO)}} </view>

<!-- 页面效果 -->
some msg
five apples

拖拽效果

<!--pages/movable/movable.wxml-->
<wxs module="test" src="./movable.wxs"></wxs>
<view wx:if="{{show}}" class="area" style='position:relative;width:100%;height:100%;'>
  <view 
    data-index="1" 
    data-obj="{{dataObj}}" 
    bindtouchstart="{{test.touchstart}}" 
    bindtouchmove="{{test.touchmove}}" 
    bindtouchend='{{test.touchmove}}' 
    class="movable" 
    style="position:absolute;width:100px;height:100px;background:{{color}};left:{{left}}px;top:{{top}}px"
    ></view>
</view>
// pages/movable/movable.js
Page({
  data: {
    left: 50,
    top: 50,
    color: 'red',
    taptest: 'taptest',
    show: true,
    dataObj: {
      obj: 1
    }
  },

  onLoad: function (options) {

  },

  onReady: function () {
    
  },
  testCallmethod(params) {
    console.log(params);
    this.setData({
      color: params.c,
    })
  }
})
// movable.wxs
var startX = 0
var startY = 0
var lastLeft = lastTop = 50
function touchstart(event, ins) {
  var touch = event.touches[0] || event.changedTouches[0]
  startX = touch.pageX
  startY = touch.pageY
  ins.callMethod('testCallmethod', {
    c: 'blue',
  })
}
function touchmove(event, ins) {
  var touch = event.touches[0] || event.changedTouches[0]
  var pageX = touch.pageX
  var pageY = touch.pageY
  var left = pageX - startX + lastLeft
  var top = pageY - startY + lastTop
  startX = pageX
  startY = pageY
  lastLeft = left
  lastTop = top
  ins.selectComponent('.movable').setStyle({
    left: left + 'px',
    top: top + 'px'
  })
}
module.exports = {
  touchstart: touchstart,
  touchmove: touchmove,
}

使用 DSL 的原因:HTML的自由度过高,通过DSL来约束管控。也增加了一定的扩展性,小程序可以在DSL转换的过程中去添加一些自己的兼容。例如:input输入框focus时被键盘遮挡的问题,小程序可以自己通过覆盖原生组件的方式来解决。

  • 第二点基础架构上的区别,小程序将逻辑层和渲染层分离,逻辑层不再置于浏览器环境中运行,逻辑侧和视图侧的通讯会经过端的转发。

  • 第三点是渲染侧多页面,逻辑侧只有一个逻辑实例

二、架构

整个小程序框架系统分为两部分:逻辑层(App Service)和 视图层(View)。小程序提供了自己的视图层描述语言 WXMLWXSS,以及基于 JavaScript 的逻辑层框架。

  • 业务逻辑侧用来执行开发者的业务逻辑代码,运行在JsCore中。

  • 渲染侧有多个Webview。

  • 渲染侧和逻辑侧不能直接通讯,而是通过的jsBridge来中转。

「 为什么选择这样的架构 」

技术选型

通常我们常见的渲染界面的方式有以下三种

原生渲染

小程序是运行在端内的,比如微信小程序的宿主是微信,字节小程序的宿主是头条APP、抖音等。使用原生技术来渲染小程序,很明显的优势是可以获得比较好的用户体验丰富的原生能力

但同时也会存在一个问题,完全使用原生来渲染小程序,那么小程序就和宿主绑定在了一起了,完全属于宿主的一部分,小程序的发布也需要依赖端的发版,这种迭代发版的节奏是肯定是不对的。

纯Web渲染

小程序需要像Web应用一样,可以随时的,直接拉取下载最新的资源包在本地运行。但纯粹的web技术无法直接调用原生能力并且也无法保证良好的用户体验。

因为在一些复杂的交互场景下,逻辑执行会阻塞UI的渲染(单线程),页面也可能会出现一些性能问题。

Hybrid 渲染

最终,小程序选择了Hybrid 方案,微信在2015年就推出了JS-SDK,其为开发者提供了丰富的微信原生能力,解决了移动端页面能力不足的问题。

同时每个页面由单独的Webview渲染,降低了每个Webview的渲染负担(多Webview的结构),定义了一系列的内置组件来统一体验。

hybrid方案


「 带来的问题 」

安全管控

由于web技术的灵活性,我们可以使用JavaScript随意直接Window的API去更改页面内容,随意使用eval执行代码,或者直接去跳转至新的页面。

这些对于小程序来说都会导致内容不可控,存在一定的安全合规风险

并且小程序提供了内置组件来统一体验,如果页面直接跳转至其他的网址上,那小程序就无法再保证统一体验了。

小程序提供一个沙箱环境来给开发者使用。沙箱内只提供JavaScript的解释执行环境,不再提供任何浏览器相关的接口,这样就将开发者的JavaScript运行环境和浏览器环境隔离开了。

所以我们看到架构图中,逻辑层是单独运行在JsCore中的,并没有在浏览器的环境中运行。

不同的运行环境

通讯的延时

因为小程序的JavaScript的环境不在Webview内了,所以它所有的通信都需要端的转发,这会带来一些延时,所以我们在涉及setData操作时,需要注意一些性能问题。

「更多的优化」

渲染层多Webview

下图是当小程序执行过两次navigateTo 之后的页面栈 [ pageA, pageB, pageC ]。

在小程序中我们路由跳转的API有 navigateTo、navigateBack、redirectTo、switchTab四种。我们以上图状态为例,来看下四个API执行之后对页面栈的影响。

当我们调用wx.navigateTo({url: ‘pageD’}) 时,此时页面栈会变成 [ pageA, pageB, pageC, pageD ]。

当我们调用wx.navigateBack()时,此时页面栈会变成 [ pageA, pageB ]。

当我们调用wx.redirectTo({url: ‘pageD’})时, 此时页面栈会变成 [ pageA, pageB, pageD ]。

当我们调用switchTab 时,看下图。

当然这个页面栈的长度不会无限增加,目前已知的是最多限制在10条。当页面栈达到10条之后,继续执行navigateTo的话,小程序会自动将其转换为redirectTo执行。

小程序开发者工具测试结果 10条

小程序开发者工具调试Element中也可以看到多个Webview

实际在小程序开发者工具中获取Webview节点,会发现超过了10个。

三、编译

3.1 文件编译

  1. JS编译:Babel 转换后 模块化处理注入一些不希望开发者使用的变量(window、document、alert等)。

  1. XXSS编译:通过postCSS转换为CSS,rpx在运行时转换所以,最后CSS会转换成JS。

  1. XXML编译:XXML解析生成DOM树,再通过Babel转换成JS。

  1. JSON编译:是一个将多个JSON合并的操作。

XXML 在WebView中是无法直接使用的,小程序这里做了一层编译转换。(各平台实现有差别)

这块有一个公式来描述编译前后的结果 :

$$$$View = render(state) $$$$

image.png

小程序将编译就是将XXML 转换成一系列的render函数,放在视图层中。等到传入路由信息后,找到对应的render函数,再将其渲染为真正的dom节点。

3.2 流程

image.png

123 为例

第一步:使用 HTML Parser 2 对 XXML 进行解析生成DOM树。

第二步:使用 Babel的能力去处理DOM节点,将其转换成抽象语法树。

第三步:使用 babel-generator 将抽象语法树转换成 js (render函数)。

第一步

使用 htmlparser2 对其进行解析 👉 htmlparser2

主要拿到三部分内容

  • Opentag 开标签 包括开标签名 属性

  • Text 文本内容

  • Closetag 闭合标签 包括闭合标签名
const { Parser } = require('htmlparser2');

const parser = new Parser({
    onopentag(name, attributes) {
        console.log('open tag  name ->', name)
        console.log('open tag  attributes ->', attributes)
    },
    ontext(text) {
        console.log("text -->", text);
    },
    onclosetag(tagname) {
       console.log('close tag  name ->', tagname)
    },
});
parser.write(
    "<view class='red'>123</view>"
);
parser.end();

运行结果

第二步 & 第三步

主要使用babel相关能力,去生成对应的语法树

参考:👉 AST Explorer 👉 @babel/types 指南

这块主要用到了 @babel/types 、 @babel/generator、@babel/template

const template = require('@babel/template').default;
const types = require('@babel/types');
const generate = require("@babel/generator").default;

const ast = types.expressionStatement(
    types.assignmentExpression(
        "=",
        types.memberExpression(
            types.identifier('exprots'),
            types.identifier('render'),
            false
        ),
        types.functionExpression(
            null,
            [types.identifier('data')],
            types.blockStatement([
                types.returnStatement(
                    types.arrayExpression([
                        types.jsxElement(
                            types.jsxOpeningElement(types.jsxIdentifier('div'),[
                                types.jsxAttribute(
                                    types.JSXIdentifier('class'),
                                    types.StringLiteral('red'),
                                )
                            ]),
                            types.jsxClosingElement(types.jsxIdentifier('div')),
                            [types.JSXText('123')]
                        )
                    ])
                )
            ])
        ),
    )
)
console.log('-------AST树-------')
console.log(ast)
console.log('-------代码-------')
console.log(generate(ast).code)

const codeTemp = template(`exprots.render = function (data) {
        return [JSX];
};`);
let templateAst = codeTemp({
    JSX: types.jsxElement(
        types.jsxOpeningElement(types.jsxIdentifier('div'),[]),
        types.jsxClosingElement(types.jsxIdentifier('div')),
        [types.JSXText('123')]
    ),
})
console.log('-------AST templateAst-------')
console.log(templateAst)
console.log('-------代码 templateAst-------')
console.log(generate(templateAst).code)

运行结果

3.3 产物

image.png

  • 各页面逻辑和路由打包。

  • 将所有页面的模版打包成渲染函数并放在一个大的渲染函数里。

  • 将所有的配置放在同一个配置文件里。

  • 最终生成一个应用入口。

四、加载过程

4.1 加载过程

  • 字节小程序通过schema打开小程序,解析schema的参数拿到对应小程序的id等信息去请求对应的小程序资源包包。小程序在下载之前会查看代码包是否已经被缓存,如果有缓存则会跳过下载。

  • 端下载并接收到小程序的内容后,分别由渲染侧去加载渲染函数、逻辑侧去加载应用入口。

  • 当两侧加载完毕后,向端发送onDocumentReady事件,端记录双方状态。

  • 然后端告知逻辑侧执行页面的生命周期事件,随后逻辑侧执行后会携带一些初始化后的数据,主动去告知渲染侧可以进行页面渲染

  • 等页面渲染完毕后主动向逻辑侧发送onDomReady事件,逻辑侧执行onReady

4.2 小程序运行时

  • 当小程序启动后,通过锁屏右上角关闭安卓/IOS 返回操作home键切换等操作后,小程序会进入后台状态。

  • 5秒后如果小程序未返回前台,将会被挂起(后台音乐、后台地理除外),JS停止执行,宿主内小程序内存状态保留,事件和接口回调会在再次切回前台后触发。

  • 如果30分钟后(字节小程序为5分钟),未切回前台,小程序被销毁。或当小程序占用内存过高时,也会被宿主主动回收。

五、通讯方式

5.1 三种通信方式

  • 逻辑侧、渲染侧主动监听端上的事件:app冷热启动,前后台切换

  • 逻辑侧、渲染侧互相通信:Data、事件传输

  • 逻辑侧、渲染侧调用端上能力:存储、文件

5.2 jsb设计

常见场景说明

  • 主动通知端:用户调用showToast时,会做一些前置的校验,回调的封装,将回调放入事件map中,然后告知端我们要调用showToast的命令,等端处理完toast的事件之后,会通过 全局暴露的handler事件去找到map去执行里面的事件。

  • 两侧通讯:接到setData命令后,首先会更新内部维护的数据,然后将数据格式化之后传输给端上,端上拿到数据之后再转发给渲染侧。

  • 监听端事件:我们无法直接去监听端的前后台切换事件,主要依赖端在事件发生后,主动通知我们去执行提前注册的相关回调。

5.3 setData使用建议

流程

  • 逻辑层虚拟 DOM 树的遍历和更新,触发组件生命周期和 observer 等;

  • 将 data 从逻辑层传输到视图层;

  • 视图层虚拟 DOM 树的更新、真实 DOM 元素的更新并触发页面渲染更新。

建议

  1. data 应只包括渲染相关的数据

    1. 与渲染无关的可以挂在data之外的字段上,this.xxx = xxx。
    2. 与渲染间接相关的可以使用纯数据字段,通过observers监听。

  1. 控制 setData 的频率

    1. 合并多次setData且仅在更新视图时使用。

  1. 选择合适的 setData 范围

    1. 可以将需要频繁更新的部分,抽离成单独的组件。

  1. setData 应只传发生变化的数据

    1. 比如使用数据路径的方式 this.setData({ 'obj.a.b.c': 'change' });

详细请参考 👉 微信:合理使用 setData

六、基础小结

小程序做的工作

image.png

© 版权声明
THE END
喜欢就支持一下吧
点赞8 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容