Jack_WJQ

Jack_WJQ 查看完整档案

长沙编辑中南林业科技大学  |  计算机科学与技术 编辑  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

Jack_WJQ 赞了文章 · 1月19日

浅谈前端测试

前端测试或许被好多人误解,也许大家更加倾向于编写面向后端的测试,逻辑性强,测试方便等

聊到这导致了好多前端从来不写测试(测试全靠手点~~~)

其实没必要达到测试驱动开发的程度,只要写完代码可以补测试,并且补出高效的测试,前端或许真的不需要手点

大前端时代不谈环境不成方圆,本文从下面几个环境一一分析下如何敏捷测试

  • node 环境
  • vue 环境
  • nuxt 服务端渲染环境
  • react 环境
  • next 服务端渲染环境
  • angular 环境

理解测试前需要补充下单元测试(unit)和端到端测试(e2e)的概念,这里不赘述

node 环境

推荐测试框架 jest

jest 是 FB 的杰作之一,方便各种场景的 js 代码测试,这里选择 jest 是因为确实方便

使用方法及配置信息可以去官方文档

配置的注意事项

{
  testEnvironment: 'node' // 如不声明默认浏览器环境
}

针对 node 只聊一下单元测试,e2e 测试比较少见

当决定写一个 npm 模块时,代码完成后必不可少的就是单元测试,单元测试需要注意的问题比较琐碎

mock

当引入三方库时,不得不 mock 数据,因为单元测试更多讲求的是局部测试,不要受外界三方引入包的影响

例如:

const { readFileSync } = require('fs')

const getFile = () => {
  try {
    const text = readFileSync('text.txt', 'utf8')
  } catch (err) {
    throw new Error(err)
  }

  console.log(text)
}

module.exports = getFile

这时我们并不需要关心 text.txt 是否真的存在,也不需要关系 text 的内容具体是什么,我们的关注点应该在于读取文件错误时能否及时抛出异常,以及 console.log() 是否如预期执行

对应到测试

const getFile = require('./getFile')

describe('readFile', () => {
  const mocks = {
    fs: {
      readFileSync: jest.fn()
    },
    other: {
      text: 'Test text'
    }
  }

  beforeAll(() => {
    jest.mock('fs', () => mocks.fs)
  })

  test('read file success run console.log', () => {
    mocks.fs.readFileSync.mockImplementation(() => this.mocks.other.text)

    getFile()

    expect(console.log).toBeCalled()
  })
})

上面代码简单的实现了一个读取文件是否成功的测试,先别急着纠错,这段测试本身是错的,下面慢慢分析

我们在最开始创建了一个 mocks 对象,用来模拟数据,由于 readFileSync 方法可能存在多种返回结果(成功或报错),所以暂时用 jest.fn() 模拟

other 里面则是放一些固定的测试数据(不会随着测试过程而改变)

beforeAll 钩子里面执行我们的 mock,把 require 进来的 fs 模块拦截调,也是本测试用例中的关键步骤

在第一个 test 里面我们改写 mocks.fs.readFileSync 的返回形式,这里使用的 mockImplementation 是直接模拟了一个执行函数,当然也可以模拟返回值,具体可以到 jest 官网

expect 用来断言我们的 console.log 方法执行了

解释了这么多测试新手们应该也都看的明白了,下面聊一下错在哪,怎么改进

  1. mockImplementation 最好替换为 mockReturnValueOnce,注意这里出现了 Once 结尾,也就是仅模拟一次返回值,mockImplementation 最好使用在复杂场景,所谓的复杂就是我们手动实现一个 readFileSync 方法使得测试达到我们预期的目的,在这个简单的场景里面我们只需要模拟返回值就好
  2. expect(console.log) 这里会报错,因为 jest 断言的内容只能是 mock function 或 spy,这里 console 是全局对象 global 上的方法,我们没有 require 将其引入,所以 jest.mock 显然处理上有些吃力,这时候 spy 就派上用场了,beforeAll 钩子里直接执行 jest.spyOn(global.console, 'log'),接下来我们就能监听到 console.log 的执行了 expect(global.console.log)
  3. 断言的目的是测试 console.log 的执行,这是不严谨的测试,我们需要使用 toBeCalledWith 来代替 toBeCalled,不仅要测试执行了,而且要测试参数正确,简单修改为 expect(global.console.log).toBeCalledWith(this.mocks.other.text)

下面补一下 read file 失败的测试

test('read file fail throw error', () => {
  mocks.fs.readFileSync.mockImplementationOnce(() => { throw new Error('readFile error') })

  expect(getFile()).toThrow()
  expect(global.console.log).not.toBeCalled()
})

读取文件失败的测试就好理解的多,注意的就是对一个 jest.fn() 多次进行修改会导致测试用例之间的相互影响,这里尽量使用 Once 结尾方法,复杂场景可以如下

beforeEach(() => {
  mocks.fs.readFileSync.mockReset()
})

每次执行 test 前先清除 mock,避免多个测试用例之间复杂化 mock 导致错误

小结:单元测试中的 mock 是个测试思路,我们无需关心外部文件和依赖是什么,只要能模拟出正确的情况程序是否按规则执行,错误的情况程序是否有异常处理,逻辑是否正确等。这样就能排除外界干扰,使得我们测试的当前一小部分是可靠的,稳定的即可。

引用外部文件

单拿出一个小结说下 require 的问题,node 9 之前不支持 es6 的 import,这里也不详细说明了。

require 本身并不复杂,但是如果搞不清楚执行时机,那么测试将无法进行,来一个例子

const env = process.env.NODE_ENV

module.export = () => env

测试如下

const getEnv = require('./getEnv')

describe('env', () => {
  test('env will be dev', () => {
    process.env.NODE_ENV = 'dev'

    expect(getEnv()).toBe('dev')
  })

  test('env will be pord', () => {
    process.env.NODE_ENV = 'pord'

    expect(getEnv()).toBe('pord')
  })
})

十分简单的测试,抛开了 mock 的流程,这里会报测试未通过,原因是 require 同时 env 已经被赋值为 undefined,我们再试着改变 NODE_ENV 环境变量时,程序不会再次执行,当然了,处理起来也十分简单

let getEnv

test('env will be dev', () => {
  process.env.NODE_ENV = 'dev'
  getEnv = require('./getEnv')

  expect(getEnv()).toBe('dev')
})

test('env will be pord', () => {
  process.env.NODE_ENV = 'pord'
  getEnv = require('./getEnv')

  expect(getEnv()).toBe('pord')
})

顺带说了一下,希望大家不要在这种低级错误上浪费时间

其实引用外部文件还有些场景会对测试带来困惑,比如动态路径,场景如下

const packageFile = `${process.cwd()}/package.json`

const package = require(packageFile)

读取当前路径下的 package.json,当测试真正跑到这段代码时会到当前目录下找 package.json,这里尽量 mock 掉 package.json 为我们自己的模拟数据,但是 jest 不支持动态路径的 mock,试着这样写 jest.mock(${process.cwd()}/package.json, () => mockFile) 会报错,所以尽量使用可以 mock 的方案,保证单元测试可以顺利进行,修改如下

const path = require('path')

const filePath = path.join(process.cwd(), 'package.json')

这样就可以 mock,path 了,和上面 mock 章节,大致思想都差不多

覆盖率

单元测试覆盖率不达标等于白测,测试过程尽量覆盖所有判断条件,而不是全部通过了就不管了,在进一阶说,100% 的测试覆盖率并不证明一定覆盖到位了,因为顺带执行的代码也会算进覆盖率,例如

module.export = (list) => list.map(({ id }) => id)

我们先不考虑这个 list 类型是不是数组,只是简单的例子,避免过度设计带来复杂化,我们测试可以这样

const getId = require('./getId')
const mocks = {
  list: [{
    id: 1,
    name: 'vue'
  }, {
    id: 2,
    name: 'react'
  }]
}

test('return id', () => {
  expect(getId(mocks.list)).toEqual([1, 2])
})

直到有一天代码变成了 module.export = (list) => [1, 2]

这时候测试还能通过,并且覆盖率 100%,的确不会有人蠢到把代码改成这样,只是一个例子,实际上逻辑会比这个复杂的多

那就聊一聊解决方案

  • mock 数据的随机化,每次测试生成随机的 list 进行测试,现有库 mockjs
  • 强关联测试,证明 map 方法的确执行了,并且参数正确,先 spy spyOn(Array.prototype, 'map') 然后断言

聊了一圈从覆盖率聊到了测试健壮性的问题,可以思考下写过的测试是否真的满足注释或修改任何一行代码都能引起测试的 pass 报错

关于 node 就聊这么多,其实下文主要思想都一样,更多的是介绍些简单可行的方案,以及可能会踩坑的地方

vue 环境

在 vue 使用场景下,无非就是组件库和业务逻辑,组件库偏向于 unit 测试,业务逻辑偏向于 e2e 测试,当然两者并不冲突

unit 测试

推荐神器:vue-test-utils

README 给了多个测试库配置的例子,这里还是推荐使用 jest,给个例子

export default {
  props: ['value'],
  data () {
    return {
      currentValue: 0
    }
  },
  watch: {
    value (val) {
      this.currentValue = val
    }
  }
}

测试如下

import { mount } from '@vue/test-utils'
import Test from './Test.vue'

test('props value', () => {
  const options = { propsData: { value: 3 } }

  const wrapper = mount(Test)

  expect(wrapper.vm.currentValue).toBe(3)
})

十分简单的例子,亮点在测试文件的 wrapper 上,通过 mount 方法创建了一个组件实例,创建过程中允许加入一些配置信息,甚至是 mock 组件中的 method 方法

vue 单元测试的范围仅限于数据流动是否正确,逻辑渲染是否正确(v-if v-show v-for),style 和 class 是否正确,我们并不需要关系这个组件在浏览器渲染中的位置,也不需要关系对其它组件会造成什么影响,只要保证组件本身正确即可,前面说的断言,vue-test-utils 都能提供对应的方案,总体上节约很多测试成本

e2e 测试

也是推荐尤大基于最新脚手架的 @vue/cli-plugin-e2e-nightwatch

e2e 测试的重点在于判断真实 DOM 是否满足预期要求,甚至很少出现 mock 场景,不可或缺的是一个浏览器运行环境,具体细节不赘述,可以看官方文档。

nuxt 服务端渲染环境

nuxt 官方推荐 ava,顺势带出 ava 的方案

unit 测试

麻烦在配置上面,先给出需要安装的依赖

"@vue/test-utils",
"ava",
"browser-env",
"require-extension-hooks",
"require-extension-hooks-babel",
"require-extension-hooks-vue",
"sinon"

在 package.json 里加几行 ava 配置

"ava": {
  "require": [
    "./tests/helpers/setup.js"
  ]
}

下面来写 ./tests/helpers/setup.js

const hooks = require('require-extension-hooks')

// Setup browser environment
require('browser-env')()

// Setup vue files to be processed by `require-extension-hooks-vue`
hooks('vue').plugin('vue').push()
// Setup vue and js files to be processed by `require-extension-hooks-babel`
hooks(['vue', 'js']).plugin('babel').push()

上面的代码唯独没看到 sinon 这个库,说到 ava 是没有 mock 功能的,这就给单元测试的 mock 带来巨大困难,不过我们可以通过引入 sinon 来解决 mock 数据的问题,在 mock 方面上 sinon 做的比 jest 还要优秀,支持沙箱模式,不影响外部数据

给个简单点的例子

<template>
  <el-card v-for="item in topicList" :key="item.id">
    <div class="card-content">
      <span class="link" @click="toMember(item.member.username)">{{ item.member.username }}</span>
    </div>
  </el-card>
</template>

<script>
export default {
  props: {
    topicList: {
      type: Array,
      required: true
    }
  },
  methods: {
    toMember (name) {
      this.$router.push(`/member/${name}`)
    }
  }
}
</script>

对应的测试代码如下

import { shallowMount } from '@vue/test-utils'
import test from 'ava'
import sinon from 'sinon'

test('methods: toMember', t => {
  const { topicList } = t.context
  const $router = {
    push: () => {}
  }
  const spy = sinon.spy($router, 'push')

  const wrapper = shallowMount(TopicListChalk, {
    propsData: { topicList },
    mocks: {
      $router
    }
  })

  topicList.forEach((item, index) => {
    const toMemberText = wrapper.findAll('.card-content').at(index).find('.link')

    toMemberText.trigger('click')

    t.true(spy.withArgs(`/member/${item.member.username}`).calledOnce)
  })
})

这里直接将 $router mock 掉,并且使用 sinon.spy 监听执行,至于 this.$router.push 后浏览器有没有跳转并不是单元测试需要关心的,这里的写法也比较特别,test 方法在回调里默认参数为 t,对应的方法都挂载在 t 对象上,上下文可通过 t.context 传递

nuxt 单元测试相关就聊这么多

e2e 测试

这里有个歧义点,nuxt 官网只给出了 e2e 的测试案例 end-to-end-testing

当使用默认脚手架构建的项目,也就是没有 server 端入口文件的项目,这个方案确实可行

但是涉及到其它框架(express|koa)的时候就显得不够用了,很有可能在自定义 server 入口是加入了大量中间件,这对于官网给出的例子是个巨大考验,不可能在每个测试文件里实现一遍 new Nuxt,所以需要更高层的封装,也就是忽略 server 启动流程的差异性,直接在浏览器中抓取页面

推荐:nuxt-jest-puppeteer

react 环境

unit 测试

这一波没得可选,jest 完胜,人家官网就有 React,RN 的支持文档

文档的案例也是十分全面,没得讲,不赘述

e2e 测试

其实上面讲了两个 e2e 的方案选择,大同小异,需要一个能在 node 跑的无头浏览器,官方没有推荐,这里站 vue 一票选择 nightwatchjs

next 服务端渲染环境

unit 测试

主要讲一下如何配置,先是依赖包

"babel-core",
"babel-jest",
"enzyme",
"enzyme-adapter-react-16",
"jest",
"react-addons-test-utils",
"react-test-renderer"

在 package.json 里面加 script "test": "NODE_ENV=test jest"

在跟路径下加 jest.config.js

module.exports = {
  setupFiles: ['<rootDir>/jest.setup.js'],
  testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/']
}

在跟路径下加 jest.setup.js

import { configure } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'

configure({
  adapter: new Adapter()
})

接下来就可以愉快的写测试了

e2e 测试

跳过了~~~

angular 环境

之所以加了这一节,还是因为多少写过一些 angular,angular 作为框架本身就是全面的,cli 新建的项目自身就带有 unit 测试和 e2e 测试

unit 测试默认是 karma + jasmine
e2e 测试默认是 protractor

也没什么可争辩的,这就是官方解决方案,用起来也方便顺手

总结

聊了好多个环境,其实行文目的主要有两方面

  • 测试思想,如何写好单元测试,主要集中在前半文
  • 测试工具推荐和相应配置

测试本身并不复杂,但是想写出高效测试并不容易,千万不要形成为了测试而测试的想法

用谎言去验证谎言得到的还是谎言。。。

大多数情况下都是项目在赶进度没空写测试,抽空把测试补上真的是一件值得去做的事情

查看原文

赞 83 收藏 63 评论 6

Jack_WJQ 回答了问题 · 2020-12-10

使用 Rollup 打包 Vue 组件,如何打包 style 标签中使用 url() 引入的图片?

顶一下,别沉了

关注 5 回答 5

Jack_WJQ 提出了问题 · 2020-12-10

使用 Rollup 打包 Vue 组件,如何打包 style 标签中使用 url() 引入的图片?

现有 rollup.config.js

import vue from 'rollup-plugin-vue';
import babel from 'rollup-plugin-babel';
import { terser } from 'rollup-plugin-terser';
import css from 'rollup-plugin-css-only';
import commonjs from '@rollup/plugin-commonjs';
import resolve from 'rollup-plugin-node-resolve';
import url from '@rollup/plugin-url';
// import postcss from 'rollup-plugin-postcss'

const production = process.env.NODE_ENV === 'production';

export default {
  input: 'src/main.js',
  output: {
    file: 'dist/bundle.js',
    format: 'esm',
    name: 'App',
    sourcemap: true
  },
  plugins: [
    vue({
      css: false
    }),
    css({ output: 'bundle.css' }),
    url({
      destDir: `./dist/assets`
    }),
    resolve(),
    commonjs(),
    babel(),
    production && terser()
  ]
};

文件目录结构:

.
├─babel.config.js
├─package.json
├─rollup.config.js
├─yarn.lock
├─src
|  ├─App.vue
|  ├─img.jpg
|  └main.js

App.vue 中的代码

<template>
  <div class="background">
    <h1>{{ message }}</h1>
    <button class="button" @click="handleButtonClick">click me</button>
  </div>
</template>

<script>
import { defineComponent, ref, watch } from 'vue';

export default defineComponent({
  name: 'App',
  setup() {
    const message = ref('');
    const clicked = ref(false);

    const handleButtonClick = () => {
      clicked.value = !clicked.value;
    };

    watch([clicked], () => {
      if (clicked.value) {
        message.value = 'Hello World!';
      } else {
        message.value = 'Hello Vue!';
      }
    });

    return {
      message,
      clicked,
      handleButtonClick
    };
  }
});
</script>

<style>
.button {
  width: 60px;
  height: 30px;
  color: aqua;
  background: orange;
  border-color: transparent;
  outline-color: transparent;
  border-radius: 6px;
}

.background {
  background-image: url('./img.jpg');
}
</style>

.vue 文件的 style 标签中使用 url() 引入的图片最终不会被打包到 dist 目录下,但是如果在 js 代码中 import img from './img.jpg 图片就能打包进 dist/assets 目录。

请问有什么方式能够解决在 style 中使用 url() 引入静态资源的打包和路径转换问题?

关注 5 回答 5

Jack_WJQ 提出了问题 · 2020-05-23

Jest 如何测试在 A 函数中调用的 B 函数?

如题,举个例子:

// ListItem.tsx
interface Props {
    onTouchStart: (e: React.TouchEvent) => void;
}

const ListItem: React.FC<Props> = (props) => {
    const handleTouchStart = (e: React.TouchEvent) => {
        // 在组件内部函数调用传入的函数
        props.onTouchStart(e);
        // 执行一些其他操作
    }

    return <div onTouchStart={handleTouchStart}></div>;
}

export default ListItem;
// ListItem.test.tsx
import { createElement } from 'rax';
import renderer from 'rax-test-renderer';

it('Test ListItem TouchStart', () => {
    const mockFunc = jest.fn();
    const component = renderer.create(<ListItem onTouchStart={mockFunc} />)
    const tree = component.toJSON();
    tree.eventListeners.touchstart();
    // 报错
    expect(tree.eventListeners.touchstart).toHaveBeenCalled();
})
expect(received).toHaveBeenCalled()

    Matcher error: received value must be a mock or spy function

    Received has type:  function
    Received has value: [Function handleTouchStart]

怎么在测试用例中测试传入props的函数是否存在?
怎么在测试用例中测试传入props的函数是否被调用?

关注 1 回答 0

Jack_WJQ 收藏了文章 · 2020-05-19

Rax,完美融合编译时与运行时的双引擎小程序框架

作者:阿里巴巴淘系前端工程师 弗申 逆葵

Rax Github Repo——https://github.com/alibaba/rax
Rax 小程序官网——https://rax.js.org/miniapp

经过持续的迭代,Rax 小程序迎来了一个大的升级,支持全新的运行时方案。站在 2020 年初这个时间点,我们想从 Rax 小程序的特点出发,进行一次全面的梳理与总结,并且在文末附上了 Rax 与当前主流的小程序开发框架的对比。本文将从 API 设计与性能双引擎架构优秀的多端组件协议设计基于 webpack 的工程架构四个方向展开。

一、API 设计与性能

当决定一个产品的技术选型的时候,我们往往会从几个方面考虑,(1)可用生态,即周边相关的工具是否满足产品开发的条件;(2)风险率,即出现问题是否能够快速定位解决,所使用的技术是否会持续维护;(3)上手成本,即需不需要很大代价才能达到能够使用的阶段;(4)性能,即能够满足产品既定的性能标准以及用户体验。

本节主要会介绍 Rax 小程序在后面两点上的优势。

API 设计

框架整体的上手成本是比较小的,Rax 小程序链路从框架上是继承自 Rax(构建多端应用的渐进式类 React 框架)。所以只要你会 Rax Web/Weex 开发或者 React,那么你就会用 Rax 开发小程序,并且可以同时投放到 Rax 所支持的其它端。

但是由于小程序端的特殊性,总会存在无法抹平以及需要单独处理的地方。得益于 Rax 已经做了比较久的多端方案,我们认为,每个端独立的属性不应该入侵基础框架本身,保证基础框架的纯净有利于做更多的扩展。

以下面的代码为例:

Taro:

import Taro, { Component } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'

export default class Index extends Component {
  config = {
    navigationBarTitleText: '首页'
  }

  componentWillMount () { }

  componentDidMount () { }

  componentWillUnmount () { }

  componentDidShow () { }

  componentDidHide () { }

  render () {
    return (
      <View>
        <Text>1</Text>
      </View>
    )
  }
}

Rax

import { createElement, Component } from 'rax';
import View from 'rax-view';
import Text from 'rax-text';
import { isMiniApp } from 'universal-api'; 
import { registerNativeListeners, addNativeEventListener, removeNativeEventListener } from 'rax-app';

function handlePageShow() {}

class Index extends Component {
  componentWillMount () { }

  componentDidMount () { 
    if (isMiniApp) {
      addNativeEventListener('onShow', handlePageShow);
    }
  }

  componentWillUnmount () {
    if (isMiniApp) {
      removeNativeEventListener('onShow', handlePageShow);
    }
  }

  render () {
    return (
      <View>
        <Text>1</Text>
      </View>
    )
  }
}

if (isMiniApp) {
  registerNativeListeners(Index, ['onShow']);
}

export default Index;

在和 Taro 的对比中,可以看出主要是两点差异:(1)Rax 没有 componentDidShowcomponentDidHide 的概念,新增了和 W3C 标准类似的 addNativeEventLisenterremoveEventListener 等 API;(2)组件实例上没有一个叫做 config 的静态属性用来设置页面的 title 等配置。

这就是前文所说的不入侵基础框架本身,React 本身其实是没有 componentDidShow 这些概念,因为这和组件本身的生命周期其实是无关的。我们更期望引导用户用标准的 API 来写业务代码。同时,这种写法的设计带来的还有性能相关的提升,后文会具体说明。

当然这种设计本身会导致代码量一定的膨胀,但是通过工程上的手段,是可以保证最后产物代码的体积几乎毫无差异。

性能

小程序的性能问题是在业务开发中经常会遇到的,为此 Rax 小程序现有的编译时方案也做了很多的努力。通过阿里小程序真机云测的功能,我们对一个无限下拉的列表做了测试。

页面结构如下:

<img data-original="https://img.alicdn.com/tfs/TB19YlZv7L0gK0jSZFxXXXWHVXa-542-962.png" width="300" />

根据真机测试报告,原生小程序三次平均是 2008 ms,Taro 是 2003ms,Rax 是 1924ms,当然其实相差并不多,但是实际的业务场景其实远比上面的页面结构更加复杂。

与 Taro 类似的,Rax 小程序侧的基础框架没有在逻辑层弄一个 VDOM,而是通过数据合并、传统的数据 diff,来避免用户更新冗余的数据。更多的是,阿里小程序原生提供了私有方法 $spliceData 来进行性能优化,Rax 底层会去识别用户需要更新的值是否是数组,然后自动根据场景使用 $spliceData 来优化渲染。

另外需要提到的是,前面说的原生事件监听,小程序本身需要预先注册才能监听事件(这也是为了保障性能),即需要:

Page({
  onShow() {}
});

而不能动态注册:

const config = {}
Page(config);
setTimeout(() => {
  config.onShow = () => {};
}, 1000);

所以加入 componentDidShow 这类概念真的不是好的做法,这会导致页面由于不知道是否需要注册 onShow 属性而将所有的原生事件全部注册监听,这不仅会造成开发者不能灵活扩展,更会导致内存泄漏的风险。

于是 Rax 小程序引入了 registerNativeListeners ,只给开发者一种新的认知,就是需要先在页面上注册事件才能进行监听。这样不仅解决了扩展性的问题,还解决了潜在的性能问题。

当然,Rax 小程序能做的性能优化到此为止了么?在可计划的未来,Rax 小程序编译时方案已经有一些明确的 action,比如进一步减轻框架对 props 更新的管理,更多的利用小程序原生的能力来实现组件更新,从而避免和小程序基础框架做重复的事情导致性能损耗。

二、双引擎架构

Rax (可能)是业界首个同时支持编译时和运行时方案的小程序解决方案。两种方案之间的切换无比简单,我们将高性能 or 完整语法的选择权真正地交给了用户。双引擎驱动的 Rax 小程序架构如下:
Rax 小程序架构
下面我们将分别介绍两种编译方案。

编译时方案

Rax 小程序编译时方案是基于 AST 转译的前提下,将 Rax 代码通过词法、语法分析转译成小程序原生代码的过程。由于 JavaScript 的动态能力以及 JSX 本身不是一个传统模板类型语法,所以在这个方案中引入一个较为轻量的运行时垫片。

Rax 小程序编译时架构的核心主要分为两个部分,AST 转译运行时垫片。下文会针对这两个部分做简要的介绍。

AST 转译

AST 转译部分的架构相比同类产品 Taro 来说,更加清晰以及可维护性更强。这里不得不提到,它的分语法场景转译以及洋葱模型。我们可以粗略的看一下,分语法场景转译部分的代码结构:

<img data-original="https://img.alicdn.com/tfs/TB19kyNwaL7gK0jSZFBXXXZZpXa-318-664.png" width="200" />

可以比较清晰的看到,针对需要转译的每一个语法场景都有一个模块专门负责转译,这就让整个转译的过程轻松了起来,只要每一部分的转译结果符合预期,那么转译结果就是符合预期的。这样的设计可以让我们能够充分利用单元测试来对转译前后的代码进行比较。

而洋葱模型的设计则是AST 转译的另一个主要设计,整个转译过程实际上分为 4 个步骤:

<img data-original="https://img.alicdn.com/tfs/TB1xNrbweH2gK0jSZJnXXaT1FXa-1592-154.png" height="60" />

洋葱模型主要进行的是后面三步,在 parser 层将原有的 AST 树修改为符合预期的新 AST 树,然后在 generate 层将新的 AST 树转译成小程序代码。

运行时垫片

由于 JSX 的动态能力以及 Rax 原本提供的一些例如 hooks 之类的特性。所以,Rax 小程序编译时方案提供了一个运行时垫片,用来对齐模拟 Rax core API 。

既然引入了运行时,自然可以基于这套机制对数据流做更多的管理,以及提供 Rax 工程在其他端上的 API,比如路由相关的 historylocation 等。

运行时方案

Rax 小程序的运行时方案没有自研,而是『站在了巨人的肩膀上』,复用了 kbone 的架构并对其作了一定程度的改造以接入 Rax 小程序的工程体系。关于运行时方案的实现原理可以点击这里查看,此处不再详细介绍。首先需要介绍的是 Rax 小程序同时也是 kbone 的优点:

  1. 支持更为完整的前端框架特性。相比较 Rax 编译时方案,现在你可以使用完整的 Rax 语法,并且 Rax 所有的特性都已经支持。忘记那些条条框框的语法约束吧;
  2. 可高度复用 Web 端逻辑。如果用户已有 Web 端的 Rax 程序代码,可能只需稍作修改,就能将整个应用完整移植到小程序端,大大提升了开发效率;
  3. 小程序端运行时,仍然可以使用小程序本身的特性,比如小程序内置组件等。

而在 kbone 的基础上,Rax 小程序运行时方案还新增了不少特性,概括起来有以下几点:

  1. 支持支付宝小程序。由于 kbone 的定位,其只支持微信小程序。Rax 基于 kbone,结合支付宝小程序的特点,拓展了对支付宝小程序的支持。现在,想在开发支付宝小程序时使用运行时方案,不仅可以使用 Remax,你还有 Rax 可以选择;
  2. 接入完整的 Rax 工程体系。现在,你可以在使用运行时方案时感受到 Rax 工程的所有特点,比如 Rax 多端 API、多端组件、多端构建器等,享受完整一致的体验;

最后,我们也不能回避的是,Rax 小程序运行时方案具有所有运行时方案都存在的问题:性能损耗。事实上,运行时方案就是以一定的性能损耗来换取更为全面的 Web 端特性支持。所以,如果你对小程序有一定的性能要求,建议使用编译时方案;如果对性能要求不高,那么运行时方案就是助你快速开发小程序的利器。双引擎驱动的 Rax 小程序,总有一处能够击中你的内心。

三、优秀的多端组件协议设计

Rax 小程序编译时方案支持项目级开发和组件级开发。与 Taro 将组件统一在项目中进行编译产出为小程序代码不同,Rax 在组件工程中即可构建出小程序组件。结合一套优秀的多端组件协议设计,我们做到了在 Rax 小程序项目和原生小程序项目中都能正常使用 Rax 小程序组件,同时保持统一的多端开发体验。该协议定义在 package.json 中的 miniappConfig 字段中,其具体用法设计可以参见文档 Rax 小程序——多端组件开发

支持渐进式接入 Rax

对于那些已经使用原生语法开发了完整的小程序的开发者来说,一个很合理的需求就是渐进地切换到 Rax 开发链路上来,毕竟整个项目迁移可能成本高昂。而 Rax 依托多端组件协议,能够帮助开发者平滑过渡。

按照设计,Rax 小程序组件工程的构建产物为符合小程序语法的组件,因此其理所当然可以直接在原生小程序项目中使用。这意味着,如果你想渐进式地使用 Rax 来开发小程序,可以以组件或者页面为单位将之前使用原生语法开发的小程序逐渐地迁移到 Rax 上来。而这,也是 Taro 等其他框架不具备的能力。在 Rax 的使用方中,浙江省网上政务平台『浙里办』支付宝小程序即采用了渐进式接入 Rax 的方式。

多端统一的组件使用体验

当使用 Rax 组件工程发布的小程序组件在 Rax 项目中使用时,构建器会自动通过 miniappConfig 规定的路径去寻找该组件的小程序实现从而实现替换。用户在业务代码编写层面无需像传统引入原生小程序组件的方式一样写具体路径,而是与 Web/Weex 端保持一致即可,示例如下:

// Wrong
import CustomComponent from 'custom-component/miniapp/index'

// Correct
import CustomComponent from 'custom-component'

除此之外,多端组件协议还可以扩展成多端组件库协议,支持更灵活的类似 import { Button } from 'fusion-mobile' 的写法。以 Rax 多端组件协议为基础,你可以快速为你的多端项目开发通用组件或者组件库。比如,Rax 基础组件就都是以该方式开发的。

四、基于 webpack 的工程架构

Rax 工程以阿里巴巴集团前端统一的 CLI 工具 @alib/build-script 为基础,其依赖 webpack,通过插件体系支持各个场景,同时基于 webpack-chain 提供了灵活的 webpack 配置能力,用户可以通过组合各种插件实现工程需求。Rax 小程序的编译时方案通过 webpack loader 来处理自身逻辑。以 app/page/component 等文件角色分类的 webpack loader 会调用 jsx-compiler 进行代码的 AST 分析及处理,再将处理完的代码交由 loader 生成对应的小程序文件;运行时方案直接复用 Web 端的编译配置,再通过额外的 webpack 插件生成具体的小程序代码。

相比其他自研的工程体系,整套架构具有如下优点:

  • 基于 webpack,灵活且可扩展型强
  • 插件体系,可复用性强
  • 命令简洁,体验统一

总结

最后,附上 Rax 和现有主流小程序框架的对比。
小程序开发框架对比
以上是我们对 Rax 小程序的核心竞争力的阶段性总结与思考。小程序已经不是初生牛犊,小程序的解决方案也早已汗牛充栋,但我们相信,Rax 的入局,会让你的小程序开发有那么一些不一样。

更多关于 Rax 小程序的内容,欢迎访问 https://rax.js.org/miniapp 了解!

查看原文

Jack_WJQ 赞了文章 · 2020-05-19

Rax,完美融合编译时与运行时的双引擎小程序框架

作者:阿里巴巴淘系前端工程师 弗申 逆葵

Rax Github Repo——https://github.com/alibaba/rax
Rax 小程序官网——https://rax.js.org/miniapp

经过持续的迭代,Rax 小程序迎来了一个大的升级,支持全新的运行时方案。站在 2020 年初这个时间点,我们想从 Rax 小程序的特点出发,进行一次全面的梳理与总结,并且在文末附上了 Rax 与当前主流的小程序开发框架的对比。本文将从 API 设计与性能双引擎架构优秀的多端组件协议设计基于 webpack 的工程架构四个方向展开。

一、API 设计与性能

当决定一个产品的技术选型的时候,我们往往会从几个方面考虑,(1)可用生态,即周边相关的工具是否满足产品开发的条件;(2)风险率,即出现问题是否能够快速定位解决,所使用的技术是否会持续维护;(3)上手成本,即需不需要很大代价才能达到能够使用的阶段;(4)性能,即能够满足产品既定的性能标准以及用户体验。

本节主要会介绍 Rax 小程序在后面两点上的优势。

API 设计

框架整体的上手成本是比较小的,Rax 小程序链路从框架上是继承自 Rax(构建多端应用的渐进式类 React 框架)。所以只要你会 Rax Web/Weex 开发或者 React,那么你就会用 Rax 开发小程序,并且可以同时投放到 Rax 所支持的其它端。

但是由于小程序端的特殊性,总会存在无法抹平以及需要单独处理的地方。得益于 Rax 已经做了比较久的多端方案,我们认为,每个端独立的属性不应该入侵基础框架本身,保证基础框架的纯净有利于做更多的扩展。

以下面的代码为例:

Taro:

import Taro, { Component } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'

export default class Index extends Component {
  config = {
    navigationBarTitleText: '首页'
  }

  componentWillMount () { }

  componentDidMount () { }

  componentWillUnmount () { }

  componentDidShow () { }

  componentDidHide () { }

  render () {
    return (
      <View>
        <Text>1</Text>
      </View>
    )
  }
}

Rax

import { createElement, Component } from 'rax';
import View from 'rax-view';
import Text from 'rax-text';
import { isMiniApp } from 'universal-api'; 
import { registerNativeListeners, addNativeEventListener, removeNativeEventListener } from 'rax-app';

function handlePageShow() {}

class Index extends Component {
  componentWillMount () { }

  componentDidMount () { 
    if (isMiniApp) {
      addNativeEventListener('onShow', handlePageShow);
    }
  }

  componentWillUnmount () {
    if (isMiniApp) {
      removeNativeEventListener('onShow', handlePageShow);
    }
  }

  render () {
    return (
      <View>
        <Text>1</Text>
      </View>
    )
  }
}

if (isMiniApp) {
  registerNativeListeners(Index, ['onShow']);
}

export default Index;

在和 Taro 的对比中,可以看出主要是两点差异:(1)Rax 没有 componentDidShowcomponentDidHide 的概念,新增了和 W3C 标准类似的 addNativeEventLisenterremoveEventListener 等 API;(2)组件实例上没有一个叫做 config 的静态属性用来设置页面的 title 等配置。

这就是前文所说的不入侵基础框架本身,React 本身其实是没有 componentDidShow 这些概念,因为这和组件本身的生命周期其实是无关的。我们更期望引导用户用标准的 API 来写业务代码。同时,这种写法的设计带来的还有性能相关的提升,后文会具体说明。

当然这种设计本身会导致代码量一定的膨胀,但是通过工程上的手段,是可以保证最后产物代码的体积几乎毫无差异。

性能

小程序的性能问题是在业务开发中经常会遇到的,为此 Rax 小程序现有的编译时方案也做了很多的努力。通过阿里小程序真机云测的功能,我们对一个无限下拉的列表做了测试。

页面结构如下:

<img data-original="https://img.alicdn.com/tfs/TB19YlZv7L0gK0jSZFxXXXWHVXa-542-962.png" width="300" />

根据真机测试报告,原生小程序三次平均是 2008 ms,Taro 是 2003ms,Rax 是 1924ms,当然其实相差并不多,但是实际的业务场景其实远比上面的页面结构更加复杂。

与 Taro 类似的,Rax 小程序侧的基础框架没有在逻辑层弄一个 VDOM,而是通过数据合并、传统的数据 diff,来避免用户更新冗余的数据。更多的是,阿里小程序原生提供了私有方法 $spliceData 来进行性能优化,Rax 底层会去识别用户需要更新的值是否是数组,然后自动根据场景使用 $spliceData 来优化渲染。

另外需要提到的是,前面说的原生事件监听,小程序本身需要预先注册才能监听事件(这也是为了保障性能),即需要:

Page({
  onShow() {}
});

而不能动态注册:

const config = {}
Page(config);
setTimeout(() => {
  config.onShow = () => {};
}, 1000);

所以加入 componentDidShow 这类概念真的不是好的做法,这会导致页面由于不知道是否需要注册 onShow 属性而将所有的原生事件全部注册监听,这不仅会造成开发者不能灵活扩展,更会导致内存泄漏的风险。

于是 Rax 小程序引入了 registerNativeListeners ,只给开发者一种新的认知,就是需要先在页面上注册事件才能进行监听。这样不仅解决了扩展性的问题,还解决了潜在的性能问题。

当然,Rax 小程序能做的性能优化到此为止了么?在可计划的未来,Rax 小程序编译时方案已经有一些明确的 action,比如进一步减轻框架对 props 更新的管理,更多的利用小程序原生的能力来实现组件更新,从而避免和小程序基础框架做重复的事情导致性能损耗。

二、双引擎架构

Rax (可能)是业界首个同时支持编译时和运行时方案的小程序解决方案。两种方案之间的切换无比简单,我们将高性能 or 完整语法的选择权真正地交给了用户。双引擎驱动的 Rax 小程序架构如下:
Rax 小程序架构
下面我们将分别介绍两种编译方案。

编译时方案

Rax 小程序编译时方案是基于 AST 转译的前提下,将 Rax 代码通过词法、语法分析转译成小程序原生代码的过程。由于 JavaScript 的动态能力以及 JSX 本身不是一个传统模板类型语法,所以在这个方案中引入一个较为轻量的运行时垫片。

Rax 小程序编译时架构的核心主要分为两个部分,AST 转译运行时垫片。下文会针对这两个部分做简要的介绍。

AST 转译

AST 转译部分的架构相比同类产品 Taro 来说,更加清晰以及可维护性更强。这里不得不提到,它的分语法场景转译以及洋葱模型。我们可以粗略的看一下,分语法场景转译部分的代码结构:

<img data-original="https://img.alicdn.com/tfs/TB19kyNwaL7gK0jSZFBXXXZZpXa-318-664.png" width="200" />

可以比较清晰的看到,针对需要转译的每一个语法场景都有一个模块专门负责转译,这就让整个转译的过程轻松了起来,只要每一部分的转译结果符合预期,那么转译结果就是符合预期的。这样的设计可以让我们能够充分利用单元测试来对转译前后的代码进行比较。

而洋葱模型的设计则是AST 转译的另一个主要设计,整个转译过程实际上分为 4 个步骤:

<img data-original="https://img.alicdn.com/tfs/TB1xNrbweH2gK0jSZJnXXaT1FXa-1592-154.png" height="60" />

洋葱模型主要进行的是后面三步,在 parser 层将原有的 AST 树修改为符合预期的新 AST 树,然后在 generate 层将新的 AST 树转译成小程序代码。

运行时垫片

由于 JSX 的动态能力以及 Rax 原本提供的一些例如 hooks 之类的特性。所以,Rax 小程序编译时方案提供了一个运行时垫片,用来对齐模拟 Rax core API 。

既然引入了运行时,自然可以基于这套机制对数据流做更多的管理,以及提供 Rax 工程在其他端上的 API,比如路由相关的 historylocation 等。

运行时方案

Rax 小程序的运行时方案没有自研,而是『站在了巨人的肩膀上』,复用了 kbone 的架构并对其作了一定程度的改造以接入 Rax 小程序的工程体系。关于运行时方案的实现原理可以点击这里查看,此处不再详细介绍。首先需要介绍的是 Rax 小程序同时也是 kbone 的优点:

  1. 支持更为完整的前端框架特性。相比较 Rax 编译时方案,现在你可以使用完整的 Rax 语法,并且 Rax 所有的特性都已经支持。忘记那些条条框框的语法约束吧;
  2. 可高度复用 Web 端逻辑。如果用户已有 Web 端的 Rax 程序代码,可能只需稍作修改,就能将整个应用完整移植到小程序端,大大提升了开发效率;
  3. 小程序端运行时,仍然可以使用小程序本身的特性,比如小程序内置组件等。

而在 kbone 的基础上,Rax 小程序运行时方案还新增了不少特性,概括起来有以下几点:

  1. 支持支付宝小程序。由于 kbone 的定位,其只支持微信小程序。Rax 基于 kbone,结合支付宝小程序的特点,拓展了对支付宝小程序的支持。现在,想在开发支付宝小程序时使用运行时方案,不仅可以使用 Remax,你还有 Rax 可以选择;
  2. 接入完整的 Rax 工程体系。现在,你可以在使用运行时方案时感受到 Rax 工程的所有特点,比如 Rax 多端 API、多端组件、多端构建器等,享受完整一致的体验;

最后,我们也不能回避的是,Rax 小程序运行时方案具有所有运行时方案都存在的问题:性能损耗。事实上,运行时方案就是以一定的性能损耗来换取更为全面的 Web 端特性支持。所以,如果你对小程序有一定的性能要求,建议使用编译时方案;如果对性能要求不高,那么运行时方案就是助你快速开发小程序的利器。双引擎驱动的 Rax 小程序,总有一处能够击中你的内心。

三、优秀的多端组件协议设计

Rax 小程序编译时方案支持项目级开发和组件级开发。与 Taro 将组件统一在项目中进行编译产出为小程序代码不同,Rax 在组件工程中即可构建出小程序组件。结合一套优秀的多端组件协议设计,我们做到了在 Rax 小程序项目和原生小程序项目中都能正常使用 Rax 小程序组件,同时保持统一的多端开发体验。该协议定义在 package.json 中的 miniappConfig 字段中,其具体用法设计可以参见文档 Rax 小程序——多端组件开发

支持渐进式接入 Rax

对于那些已经使用原生语法开发了完整的小程序的开发者来说,一个很合理的需求就是渐进地切换到 Rax 开发链路上来,毕竟整个项目迁移可能成本高昂。而 Rax 依托多端组件协议,能够帮助开发者平滑过渡。

按照设计,Rax 小程序组件工程的构建产物为符合小程序语法的组件,因此其理所当然可以直接在原生小程序项目中使用。这意味着,如果你想渐进式地使用 Rax 来开发小程序,可以以组件或者页面为单位将之前使用原生语法开发的小程序逐渐地迁移到 Rax 上来。而这,也是 Taro 等其他框架不具备的能力。在 Rax 的使用方中,浙江省网上政务平台『浙里办』支付宝小程序即采用了渐进式接入 Rax 的方式。

多端统一的组件使用体验

当使用 Rax 组件工程发布的小程序组件在 Rax 项目中使用时,构建器会自动通过 miniappConfig 规定的路径去寻找该组件的小程序实现从而实现替换。用户在业务代码编写层面无需像传统引入原生小程序组件的方式一样写具体路径,而是与 Web/Weex 端保持一致即可,示例如下:

// Wrong
import CustomComponent from 'custom-component/miniapp/index'

// Correct
import CustomComponent from 'custom-component'

除此之外,多端组件协议还可以扩展成多端组件库协议,支持更灵活的类似 import { Button } from 'fusion-mobile' 的写法。以 Rax 多端组件协议为基础,你可以快速为你的多端项目开发通用组件或者组件库。比如,Rax 基础组件就都是以该方式开发的。

四、基于 webpack 的工程架构

Rax 工程以阿里巴巴集团前端统一的 CLI 工具 @alib/build-script 为基础,其依赖 webpack,通过插件体系支持各个场景,同时基于 webpack-chain 提供了灵活的 webpack 配置能力,用户可以通过组合各种插件实现工程需求。Rax 小程序的编译时方案通过 webpack loader 来处理自身逻辑。以 app/page/component 等文件角色分类的 webpack loader 会调用 jsx-compiler 进行代码的 AST 分析及处理,再将处理完的代码交由 loader 生成对应的小程序文件;运行时方案直接复用 Web 端的编译配置,再通过额外的 webpack 插件生成具体的小程序代码。

相比其他自研的工程体系,整套架构具有如下优点:

  • 基于 webpack,灵活且可扩展型强
  • 插件体系,可复用性强
  • 命令简洁,体验统一

总结

最后,附上 Rax 和现有主流小程序框架的对比。
小程序开发框架对比
以上是我们对 Rax 小程序的核心竞争力的阶段性总结与思考。小程序已经不是初生牛犊,小程序的解决方案也早已汗牛充栋,但我们相信,Rax 的入局,会让你的小程序开发有那么一些不一样。

更多关于 Rax 小程序的内容,欢迎访问 https://rax.js.org/miniapp 了解!

查看原文

赞 1 收藏 1 评论 0

Jack_WJQ 提出了问题 · 2020-03-28

e.target 是否能够在事件捕获阶段被获取到?addEventListener 如何让事件在捕获阶段就执行?

如题,写了一个小的 Demo,不知道是否能够帮助理解题意。
codepen

关注 2 回答 1

Jack_WJQ 回答了问题 · 2020-02-11

vue-router.esm.js?9bfb:2085 Uncaught (in promise) undefined

你这样问问题谁能帮你忙?就好像有地方着火了你报警说着火了又不说在什么地方一样

关注 2 回答 3

Jack_WJQ 提出了问题 · 2020-02-11

Vue 中使用百度地图 API 进行逆地址解析,如何等待回调完成后再执行后续操作?

      var myGeo = new window.BMap.Geocoder()

      // 获取经纬度
      var getPosition = function(data) {
        var position = {}
        data.forEach(item => {
          myGeo.getPoint(
            item.name,
            point => {
              if (point) {
                position[item.name] = [point.lng, point.lat]
                console.log(`${item.name}: ${[point.lng, point.lat]}`)
              }
            },
            item.name
          )
        })
        return position
      }

      // 经纬度信息
      var geoCoordMap = getPosition(data)

相关的代码如上,在getPosition方法中,有一个调用的百度地图的 API 进行逆地址解析,回调函数可能由于网络的原因在返回之后才执行,如何将这段代码改为等待回调函数全部执行完毕后,再返回position对象?

关注 3 回答 2

Jack_WJQ 提出了问题 · 2020-02-10

解决echarts 地图散点图官方示例中存在的 bug,是否有解决办法?

image.png
image.png
image.png

示例链接:https://www.echartsjs.com/exa...

在这个官方示例中,武汉的 pm2.5 指数命名是 273,但是在地图中的 tooltip 上显示的是武汉的纬度数值。

在自己使用这个组件的时候也遇到的这样的问题,请问有什么方法能够解决这个 bug ?
按照官方示例,设置 tooltip 的数据时[经度,纬度,数值],但这里明显显示错误。

关注 1 回答 1

认证与成就

  • 获得 6 次点赞
  • 获得 25 枚徽章 获得 0 枚金徽章, 获得 2 枚银徽章, 获得 23 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2018-12-22
个人主页被 653 人浏览