前端向后

前端向后 查看完整档案

填写现居城市  |  填写毕业院校「前端向后」微信公众号  |  master 编辑填写个人主网站
编辑

个人动态

前端向后 关注了用户 · 1月19日

高阳Sunny @sunny

SegmentFault 思否 CEO
C14Z.Group Founder
Forbes China 30U30

独立思考 敢于否定

曾经是个话痨... 要做一个有趣的人!

任何问题可以给我发私信或者发邮件 sunny@sifou.com

关注 2148

前端向后 发布了文章 · 1月19日

从 Next.js 看企业级框架的 SSR 支持

一.Next.js 简介

The React Framework for Production

面向生产使用的 React 框架(废话)。提供了好些开箱即用的特性,支持静态渲染/服务端渲染混用、支持 TypeScript、支持打包优化、支持按路由预加载等等:

Next.js gives you the best developer experience with all the features you need for production: hybrid static & server rendering, TypeScript support, smart bundling, route pre-fetching, and more. No config needed.

其中,完善的静态渲染/服务端渲染支持让 Next.js 在 React 生态中独树一帜

二.核心特性

如果说 Next.js 只做了一件事,那就是预渲染(Pre-rendering

By default, Next.js pre-renders every page. This means that Next.js generates HTML for each page in advance, instead of having it all done by client-side JavaScript.

具体的,预渲染分为两种方式:

  • SSG(Static Site Generation):也叫 Static Generation,在编译时生成静态 HTML
  • SSR(Server-Side Rendering):也叫 Server Rendering,用户请求到来时动态生成 HTML

与 SSR 相比,Next.js 更推崇的是 SSG,因为其性能优势更大(静态内容可托管至 CDN,性能提升立竿见影)。因此建议优先考虑 SSG,只在 SSG 无法满足的情况下(比如一些无法在编译时静态生成的个性化内容)才考虑 SSR、CSR

P.S.CSR、SSR 等更多渲染模式,见前端渲染模式的探索

围绕核心的预渲染功能,延伸出了一系列相关支持,如:

  • 路由(文件规范、API):多页面的基础
  • 页面级预渲染、代码拆分:顺理成章
  • 增量静态生成:针对大量页面的编译时预渲染(即静态生成)策略
  • 按路由预加载:锦上添花
  • 国际化(结合路由):锦上添花
  • 集成 Serverless 函数:锦上添花
  • 自动 polyfill、自定义head标签:友情赠送

此外,还提供了一些通用场景支持:

三.路由支持

Next.js 提供了两种路由支持,静态路由与动态路由

静态路由

静态路由通过文件规范来约定,pages目录下的js文件都认为是路由(每个静态路由对应一个页面文件),例如:

pages/index.js → /
pages/blog/index.js → /blog
pages/blog/first-post.js → /blog/first-post
pages/dashboard/settings/username.js → /dashboard/settings/username

动态路由

类似的,动态路由也要在pages目录下创建文件,只是文件名有些不同寻常:

pages/blog/[slug].js → /blog/:slug (/blog/hello-world)
pages/[username]/settings.js → /:username/settings (/foo/settings)
pages/post/[...all].js → /post/* (/post/2020/id/title)

路径中变化的参数通过getStaticPaths来填充:

// pages/posts/[id].js
export async function getStaticPaths() {
  return {
    // 必须叫paths,值必须是数组
    paths: [{
      // 每一项必须是这个形式
      params: {
        // 必须含有id
        id: 'ssg-ssr'
      }
    },{
      params: {
        id: 'pre-rendering'
      }
    }],
    fallback: false
  }
}

进一步传递给getStaticProps按参数获取数据,并渲染页面:

// pages/posts/[id].js
export async function getStaticProps({ params }) {
  // 根据路由参数获取相应数据
  const postData = await getPostData(params.id)
  return {
    props: {
      postData
    }
  }
}

// 渲染页面
export default function Post({ postData }) {
  return (
    <Layout>
      <Head>
        <title>{postData.title}</title>
      </Head>
      <article>
        <h1 className={utilStyles.headingXl}>{postData.title}</h1>
        <div className={utilStyles.lightText}>
          <Date dateString={postData.date} />
        </div>
        <div dangerouslySetInnerHTML={{ __html: postData.contentHtml }} />
      </article>
    </Layout>
  )
}

可以理解为先创建一个工厂 page(例如pages/[路由参数1]/[路由参数2].js),接着getStaticPaths填充路由参数,getStaticProps({ params })根据参数请求不同数据,最后数据进入页面组件开始预渲染:

四.SSG 支持

最简单,同时性能也最优的预渲染方式就是静态生成(SSG),把组件渲染工作完全前移到编译时

  1. (编译时)获取数据
  2. (编译时)渲染组件,生成 HTML

将生成的 HTML 静态资源托管到 Web 服务器或 CDN 即可,兼具 React 工程优势与 Web 极致性能

那么首先要解决如何获取数据的问题,Next.js 的做法是将页面依赖的数据集中管理起来:

// pages/index.js
export default function Home(props) { ... }

// 获取静态数据
export async function getStaticProps() {
  // Get external data from the file system, API, DB, etc.
  const data = ...

  // The value of the `props` key will be
  //  passed to the `Home` component
  return {
    props: ...
  }
}

其中,getStaticProps只在服务端执行(根本不会进入客户端 bundle),返回的静态数据会传递给页面组件(上例中的Home)。也就是说,要求通过getStaticProps提前备好页面所依赖的全部数据,数据 ready 之后组件才开始渲染,并生成 HTML

P.S.注意,只有页面能通过getStaticProps声明其数据依赖,普通组件不允许,所以要求将整页依赖的所有数据都组织到一处

至于渲染生成 HTML 的部分,借助React 提供的 SSR API即可完成

至此,只要是依赖数据有办法提前获取到的页面,理论上都可以编译生成静态 HTML,但 2 个问题也随之而来:

  • 数据可能会发生变化,已经生成的静态页面需要更新
  • 数据量可能会多到“永远”编译不完

以电商页面为例,要把海量商品数据全都编译成静态页面,几乎是不可能的(或许要编译一个世纪那么长),即便都生成了,商品信息也会时不时地更新,静态页面需要重新生成:

If your app has a very large number of static pages that depend on data (think: a very large e-commerce site). You want to pre-render all product pages, but then your builds would take forever.

因此,增量静态再生成(Incremental Static Regeneration)应运而生

ISR 支持

对于编译时无法穷举的海量页面以及需要更新的场景,Next.js 允许运行时再生成(相当于运行时静态化):

Incremental Static Regeneration allows you to update existing pages by re-rendering them in the background as traffic comes in.

例如:

export async function getStaticProps() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()

  return {
    props: {
      posts,
    },
    // 设置有效期,开启ISR
    revalidate: 1, // In seconds
  }
}

revalidate: 1表示运行时(用户请求打过来时)尝试重新生成静态 HTML,1秒最多重新生成一次

运行时静态生成需要一些时间(用户请求等着要 HTML),在此过程中有 3 种选择:

  • fallback: false:不降级,命中尚未生成静态页面的路由直接 404
  • fallback: true:降级,命中尚未生成静态页面的路由先返回降级页面(此时props为空,一般显示个 loading),静态生成 HTML 的同时会生成一份 JSON 供降级页面 CSR 使用,完成之后浏览器拿到数据(在客户端填上props),渲染出完整页面
  • fallback: 'blocking':不降级,并且要求用户请求一直等到新页面静态生成结束(实际上就是 SSR,渲染过程是阻塞的,只是完成之后会保留结果 HTML)

即结合路由(getStaticPaths)对尚未生成的页面进行降级,例如:

// pages/index.js
import { useRouter } from 'next/router'

function Post({ post }) {
  const router = useRouter()

  // 渲染降级页面
  if (router.isFallback) {
    return <div>Loading...</div>
  }

  // Render post...
}

export async function getStaticPaths() {
  return {
    paths: [{ params: { id: '1' } }, { params: { id: '2' } }],
    // (页面级)降级策略,true表示遇到尚未生成的先给个降级页,生成完毕后客户端自动更新过来
    fallback: true,
  }
}

P.S.具体见Incremental Static Regeneration、以及The fallback key

然而,并非所有场景都能愉快地在编译时静态生成。典型的,如果组件依赖的数据是动态的,显然无法在编译时预先取得数据,静态生成就无从谈起了

五.SSR 支持

对于编译时无法生成静态页面的场景,就不得不考虑 SSR 了:

区别于 SSG getStaticProps,Next.js 提供了 SSR 专用的getServerSideProps(context)

// pages/index.js
export async function getServerSideProps(context) {
  const res = await fetch(`https://...`)
  const data = await res.json()

  if (!data) {
    return {
      notFound: true,
    }
  }

  return {
    props: {}, // will be passed to the page component as props
  }
}

同样用来获取数据,与getStaticProps最大的区别在于每个请求过来时都执行,所以能够拿到请求上下文参数context

P.S.更多详细信息,见getServerSideProps (Server-side Rendering)

六.总结

围绕预渲染如何获取数据的问题,Next.js 探索出了别致的路由支持和精巧的 SSG、SSR 支持。不仅如此,Next.js 还提供了鱼和熊掌可以兼得的混用支持,不同渲染模式结合起来到底有多厉害,且看下篇分解

参考资料

有所得、有所惑,真好

关注「前端向后」微信公众号,你将收获一系列「用原创」的高质量技术文章,主题包括但不限于前端、Node.js以及服务端技术

本文首发于 ayqy.net ,原文链接 http://www.ayqy.net/blog/next...

查看原文

赞 8 收藏 3 评论 1

前端向后 发布了文章 · 2020-12-01

4图看懂React SSR中的hydrate

React CSR:水车模型

当初在理解 React CSR 时做过一个比喻,把单向数据流比作瀑布模型

瀑布模型:由props(水管)和state(水源)把组件组织起来,组件间数据流向类似于瀑布。数据流向总是从祖先到子孙(从根到叶子),不会逆流

(摘自深入 React

单组件的微观视角下,我们把props理解为水管(数据通道),接收外部传递进来的数据(水),每一份state都是一处水源(想象泉眼冒水,即产生数据的地方),将这棵通过props管道连接而成的组件树立起来,就形成了自上而下的水流(瀑布):

想象上图整面瀑布墙上有无数的泉眼,state值顺着props管道流淌

从更宏大的视角来看,组件树就像是一系列竹管连接起来的水车,数据是水源(statepropscontext以及外部数据源),水自上而下地流经整个组件树到达叶子组件,渲染出漂亮的视图

先通过一张图来感受竹管输水:

再感受水源以及水车整体的运转:

左侧的小桶就是外部数据源,随时舀起一瓢灌到某个组件(竹管)中,让其内部的state(储水)发生变化,变化的水流经过整个子树到达叶子组件,渲染出变化后的视图,这就是交互操作导致数据变化时的组件更新过程

React SSR:三体人模型

CSR 模式下,我们把水理解为数据,同样适用于 SSR,只是过程稍复杂些:

  1. 服务端渲染:在服务端注入数据,构建出组件树
  2. 序列化成 HTML:脱水成人干
  3. 客户端渲染:到达客户端后泡水,激活水流,变回活人

类比三体人的生存模式,乱纪元来临时先脱水成人干(SSR 中的服务端渲染部分),恒纪元到来后再泡水复活(SSR 中的客户端 hydrate 部分)

喝水(render)

首先要有水可脱,所以先要拉取数据(水),在服务端完成组件首次渲染(mount)的过程:

也就是根据外部数据构建出初始组件树,过程中仅执行render及之前的几个生命周期,是为了尽可能缩短保命招数的前摇,尽快脱水

脱水(dehydrate)

接着对组件树进行脱水,使其在恶劣的环境同样能够以一种更简单的形态“生存”下来,比如禁用了 JavaScript 的客户端环境

比组件树更简单的形态是 HTML 片段,脱去生命的水气(动态数据),成为风干标本一样的静态快照:

内存里的组件树被序列化成了静态的 HTML 片段,还能看出来人样(初始视图),不过已经无法与之交互了,但这种便携的形态尤其适合运输,能够通过网络传输到地球上的某个客户端

注水(hydrate)

抵达客户端后,如果环境适宜(没有禁用 JavaScript),就立即开始“浸泡”(hydrate),组件随之复苏

客户端“浸泡”的过程实际上是重新创建了组件树,将新生的水(statepropscontext等)注入其中,并将鲜活的组件树塞进服务端渲染的干瘪躯壳里,使之复活

注水复活其实比三体人浸泡复苏更强大一些,能够修复肢体性的损伤(缺失的 HTML 结构会重新创建),但并不纠正口歪眼斜之类的小毛病(忽略属性多了少了、属性值对不上之类的问题,具体见React SSR 之原理篇

P.S.浸泡也需要一定时间,所以在 SSR 模式下,客户端有一段时间是无法正常交互的,注水完成之后才能彻底复活(单向数据流和交互行为都恢复正常)

参考资料

有所得、有所惑,真好

关注「前端向后」微信公众号,你将收获一系列「用原创」的高质量技术文章,主题包括但不限于前端、Node.js以及服务端技术

本文首发于 ayqy.net ,原文链接:http://www.ayqy.net/blog/ssr-...

查看原文

赞 1 收藏 0 评论 0

前端向后 发布了文章 · 2020-11-27

React SSR源码剖析

写在前面

上篇React SSR 之 API 篇细致介绍了 React SSR 相关 API 的作用,本篇将深入源码,围绕以下 3 个问题,弄清楚其实现原理:

  • React 组件是怎么变成 HTML 字符串的?
  • 这些字符串是如何边拼接边流式发送的?
  • hydrate 究竟做了什么?

一.React 组件是怎么变成 HTML 字符串的?

输入一个 React 组件:

class MyComponent extends React.Component {
  constructor() {
    super();
    this.state = {
      title: 'Welcome to React SSR!',
    };
  }

  handleClick() {
    alert('clicked');
  }

  render() {
    return (
      <div>
        <h1 className="site-title" onClick={this.handleClick}>{this.state.title} Hello There!</h1>
      </div>
    );
  }
}

ReactDOMServer.renderToString()处理后输出 HTML 字符串:

'<div data-reactroot=""><h1 class="site-title">Welcome to React SSR!<!-- --> Hello There!</h1></div>'

这中间发生了什么?

首先,创建组件实例,再执行render及之前的生命周期,最后将 DOM 元素映射成 HTML 字符串

创建组件实例

inst = new Component(element.props, publicContext, updater);

通过第三个参数updater注入了外部updater,用来拦截setState等操作:

var updater = {
  isMounted: function (publicInstance) {
    return false;
  },
  enqueueForceUpdate: function (publicInstance) {
    if (queue === null) {
      warnNoop(publicInstance, 'forceUpdate');
      return null;
    }
  },
  enqueueReplaceState: function (publicInstance, completeState) {
    replace = true;
    queue = [completeState];
  },
  enqueueSetState: function (publicInstance, currentPartialState) {
    if (queue === null) {
      warnNoop(publicInstance, 'setState');
      return null;
    }

    queue.push(currentPartialState);
  }
};

与先前维护虚拟 DOM 的方案相比,这种拦截状态更新的方式更快

In React 16, though, the core team rewrote the server renderer from scratch, and it doesn’t do any vDOM work at all. This means it can be much, much faster.

(摘自What’s New With Server-Side Rendering in React 16

替换 React 内置 updater 的部分位于 React.Component 基类的构造器中:

function Component(props, context, updater) {
  this.props = props;
  this.context = context; // If a component has string refs, we will assign a different object later.

  this.refs = emptyObject; // We initialize the default updater but the real one gets injected by the
  // renderer.

  this.updater = updater || ReactNoopUpdateQueue;
}

渲染组件

拿到初始数据(inst.state)后,依次执行组件生命周期函数:

// getDerivedStateFromProps
var partialState = Component.getDerivedStateFromProps.call(null, element.props, inst.state);
inst.state = _assign({}, inst.state, partialState);

// componentWillMount
if (typeof Component.getDerivedStateFromProps !== 'function') {
  inst.componentWillMount();
}

// UNSAFE_componentWillMount
if (typeof inst.UNSAFE_componentWillMount === 'function' && typeof Component.getDerivedStateFromProps !== 'function') {
  // In order to support react-lifecycles-compat polyfilled components,
  // Unsafe lifecycles should not be invoked for any component with the new gDSFP.
  inst.UNSAFE_componentWillMount();
}

注意新旧生命周期的互斥关系,优先getDerivedStateFromProps,若不存在才会执行componentWillMount/UNSAFE_componentWillMount,特殊的,如果这两个旧生命周期函数同时存在,会按以上顺序把两个函数都执行一遍

接下来准备render了,但在此之前,先要检查updater队列,因为componentWillMount/UNSAFE_componentWillMount可能会引发状态更新:

if (queue.length) {
  var nextState = oldReplace ? oldQueue[0] : inst.state;
  for (var i = oldReplace ? 1 : 0; i < oldQueue.length; i++) {
    var partial = oldQueue[i];
    var _partialState = typeof partial === 'function' ? partial.call(inst, nextState, element.props, publicContext) : partial;
    nextState = _assign({}, nextState, _partialState);
  }
  inst.state = nextState;
}

接着进入render

child = inst.render();

并递归向下对子组件进行同样的处理(processChild):

while (React.isValidElement(child)) {
  // Safe because we just checked it's an element.
  var element = child;
  var Component = element.type;

  if (typeof Component !== 'function') {
    break;
  }

  processChild(element, Component);
}

直至遇到原生 DOM 元素(组件类型不为function),将 DOM 元素“渲染”成字符串并输出:

if (typeof elementType === 'string') {
  return this.renderDOM(nextElement, context, parentNamespace);
}

“渲染”DOM 元素

特殊的,先对受控组件props进行预处理:

// input
props = _assign({
  type: undefined
}, props, {
  defaultChecked: undefined,
  defaultValue: undefined,
  value: props.value != null ? props.value : props.defaultValue,
  checked: props.checked != null ? props.checked : props.defaultChecked
});

// textarea
props = _assign({}, props, {
  value: undefined,
  children: '' + initialValue
});

// select
props = _assign({}, props, {
  value: undefined
});

// option
props = _assign({
  selected: undefined,
  children: undefined
}, props, {
  selected: selected,
  children: optionChildren
});

接着正式开始拼接字符串,先创建开标签:

// 创建开标签
var out = createOpenTagMarkup(element.type, tag, props, namespace, this.makeStaticMarkup, this.stack.length === 1);

function createOpenTagMarkup(tagVerbatim, tagLowercase, props, namespace, makeStaticMarkup, isRootElement) {
  var ret = '<' + tagVerbatim;
  for (var propKey in props) {
    var propValue = props[propKey];
    // 序列化style值
    if (propKey === STYLE) {
      propValue = createMarkupForStyles(propValue);
    }
    // 创建标签属性
    var markup = null;
    markup = createMarkupForProperty(propKey, propValue);
    // 拼上到开标签上
    if (markup) {
      ret += ' ' + markup;
    }
  }

  // renderToStaticMarkup() 直接返回干净的HTML标签
  if (makeStaticMarkup) {
    return ret;
  }
  // renderToString() 给根元素添上额外的react属性 data-reactroot=""
  if (isRootElement) {
    ret += ' ' + createMarkupForRoot();
  }

  return ret;
}

再创建闭标签:

// 创建闭标签
var footer = '';
if (omittedCloseTags.hasOwnProperty(tag)) {
  out += '/>';
} else {
  out += '>';
  footer = '</' + element.type + '>';
}

并处理子节点:

// 文本子节点,直接拼到开标签上
var innerMarkup = getNonChildrenInnerMarkup(props);
if (innerMarkup != null) {
  out += innerMarkup;
} else {
  children = toArray(props.children);
}
// 非文本子节点,开标签输出(返回),闭标签入栈
var frame = {
  domNamespace: getChildNamespace(parentNamespace, element.type),
  type: tag,
  children: children,
  childIndex: 0,
  context: context,
  footer: footer
};
this.stack.push(frame);
return out;

注意,此时完整的 HTML 片段虽然尚未渲染完成(子节点并未转出 HTML,所以闭标签也没办法拼上去),但开标签部分已经完全确定,可以输出给客户端了

二.这些字符串是如何边拼接边流式发送的?

如此这般,每趟只渲染一个节点,直到栈中没有待完成的渲染任务为止

function read(bytes) {
  try {
    var out = [''];

    while (out[0].length < bytes) {
      if (this.stack.length === 0) {
        break;
      }

      // 取栈顶的渲染任务
      var frame = this.stack[this.stack.length - 1];

      // 该节点下所有子节点都渲染完毕
      if (frame.childIndex >= frame.children.length) {
        var footer = frame.footer;
        // 当前节点(的渲染任务)出栈
        this.stack.pop();
        // 拼上闭标签,当前节点打完收工
        out[this.suspenseDepth] += footer;
        continue;
      }

      // 每处理一个子节点,childIndex + 1
      var child = frame.children[frame.childIndex++];
      var outBuffer = '';

      try {
        // 渲染一个节点
        outBuffer += this.render(child, frame.context, frame.domNamespace);
      } catch (err) { /*...*/ }

      out[this.suspenseDepth] += outBuffer;
    }

    return out[0];
  } finally { /*...*/ }
}

这种细粒度的任务调度让流式边拼接边发送成为了可能,与React Fiber 调度机制异曲同工,同样是小段任务,Fiber 调度基于时间,SSR 调度基于工作量while (out[0].length < bytes)

按给定的目标工作量(bytes)一块一块地输出,这正是的基本特性:

stream 是数据集合,与数组、字符串差不多。但 stream 不一次性访问全部数据,而是一部分一部分发送/接收(chunk 式的)

生产者的生产模式已经完全符合流的特性了,因此,只需要将其包装成 Readable Stream 即可:

function ReactMarkupReadableStream(element, makeStaticMarkup, options) {
  var _this;

  // 创建 Readable Stream
  _this = _Readable.call(this, {}) || this;
  // 直接使用 renderToString 的渲染逻辑
  _this.partialRenderer = new ReactDOMServerRenderer(element, makeStaticMarkup, options);
  return _this;
}

var _proto = ReactMarkupReadableStream.prototype;
// 重写 _read() 方法,每次读指定 size 的字符串
_proto._read = function _read(size) {
  try {
    this.push(this.partialRenderer.read(size));
  } catch (err) {
    this.destroy(err);
  }
};

异常简单:

function renderToNodeStream(element, options) {
  return new ReactMarkupReadableStream(element, false, options);
}

P.S.至于非流式 API,则是一次性读完(read(Infinity)):

function renderToString(element, options) {
  var renderer = new ReactDOMServerRenderer(element, false, options);

  try {
    var markup = renderer.read(Infinity);
    return markup;
  } finally {
    renderer.destroy();
  }
}

三.hydrate 究竟做了什么?

组件在服务端被灌入数据,并“渲染”成 HTML 后,在客户端能够直接呈现出有意义的内容,但并不具备交互行为,因为上面的服务端渲染过程并没有处理onClick等属性(其实是故意忽略了这些属性):

function shouldIgnoreAttribute(name, propertyInfo, isCustomComponentTag) {
  if (name.length > 2 && (name[0] === 'o' || name[0] === 'O') && (name[1] === 'n' || name[1] === 'N')) {
    return true;
  }
}

也没有执行render之后的生命周期,组件没有被完整地“渲染”出来。因此,另一部分渲染工作仍然要在客户端完成,这个过程就是 hydrate

hydrate 与 render 的区别

hydrate()render()拥有完全相同的函数签名,都能在指定容器节点上渲染组件:

ReactDOM.hydrate(element, container[, callback])
ReactDOM.render(element, container[, callback])

但不同于render()从零开始,hydrate()是发生在服务端渲染产物之上的,所以最大的区别是 hydrate 过程会复用服务端已经渲染好的 DOM 节点

节点复用策略

hydrate 模式下,组件渲染过程同样分为两个阶段

  • 第一阶段(render/reconciliation):找到可复用的现有节点,挂到fiber节点的stateNode
  • 第二阶段(commit):diffHydratedProperties决定是否需要更新现有节点,规则是看 DOM 节点上的attributesprops是否一致

也就是说,在对应位置找到一个“可能被复用的”(hydratable)现有 DOM 节点,暂时作为渲染结果记下,接着在 commit 阶段尝试复用该节点

选择现有节点具体如下:

// renderRoot的时候取第一个(可能被复用的)子节点
function updateHostRoot(current, workInProgress, renderLanes) {
  var root = workInProgress.stateNode;
  // hydrate模式下,从container中找出第一个可用子节点
  if (root.hydrate && enterHydrationState(workInProgress)) {
    var child = mountChildFibers(workInProgress, null, nextChildren, renderLanes);
    workInProgress.child = child;
  }
}

function enterHydrationState(fiber) {
  var parentInstance = fiber.stateNode.containerInfo;
  // 取第一个(可能被复用的)子节点,记到模块级全局变量上
  nextHydratableInstance = getFirstHydratableChild(parentInstance);
  hydrationParentFiber = fiber;
  isHydrating = true;
  return true;
}

选择标准是节点类型为元素节点(nodeType1)或文本节点(nodeType3):

// 找出兄弟节点中第一个元素节点或文本节点
function getNextHydratable(node) {
  for (; node != null; node = node.nextSibling) {
    var nodeType = node.nodeType;

    if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) {
      break;
    }
  }

  return node;
}

预选节点之后,渲染到原生组件(HostComponent)时,会将预选的节点挂到fiber节点的stateNode上:

// 遇到原生节点
function updateHostComponent(current, workInProgress, renderLanes) {
  if (current === null) {
    // 尝试复用预选的现有节点
    tryToClaimNextHydratableInstance(workInProgress);
  }
}

function tryToClaimNextHydratableInstance(fiber) {
  // 取出预选的节点
  var nextInstance = nextHydratableInstance;
  // 尝试复用
  tryHydrate(fiber, nextInstance);
}

以元素节点为例(文本节点与之类似):

function tryHydrate(fiber, nextInstance) {
  var type = fiber.type;
  // 判断预选节点是否匹配
  var instance = canHydrateInstance(nextInstance, type);

  // 如果预选的节点可复用,就挂到stateNode上,暂时作为渲染结果记下来
  if (instance !== null) {
    fiber.stateNode = instance;
    return true;
  }
}

注意,这里并不检查属性是否完全匹配,只要元素节点的标签名相同(如divh1),就认为可复用

function canHydrateInstance(instance, type, props) {
  if (instance.nodeType !== ELEMENT_NODE || type.toLowerCase() !== instance.nodeName.toLowerCase()) {
    return null;
  }
  return instance;
}

在第一阶段的收尾部分(completeWork)进行属性的一致性检查,而属性值纠错实际发生在第二阶段:

function completeWork(current, workInProgress, renderLanes) {
  var _wasHydrated = popHydrationState(workInProgress);
  // 如果存在匹配成功的现有节点
  if (_wasHydrated) {
    // 检查是否需要更新属性
    if (prepareToHydrateHostInstance(workInProgress, rootContainerInstance, currentHostContext)) {
      // 纠错动作放到第二阶段进行
      markUpdate(workInProgress);
    }
  }
  // 否则document.createElement创建节点
  else {
    var instance = createInstance(type, newProps, rootContainerInstance, currentHostContext, workInProgress);
    appendAllChildren(instance, workInProgress, false, false);
    workInProgress.stateNode = instance;

    if (finalizeInitialChildren(instance, type, newProps, rootContainerInstance)) {
      markUpdate(workInProgress);
    }
  }
}

一致性检查就是看 DOM 节点上的attributes与组件props是否一致,主要做 3 件事情:

  • 文本子节点值不同报警告并纠错(用客户端状态修正服务端渲染结果)
  • 其它styleclass值等不同只警告,并不纠错
  • DOM 节点上有多余的属性,也报警告

也就是说,只在文本子节点内容有差异时才会自动纠错,对于属性数量、值的差异只是抛出警告,并不纠正,因此,在开发阶段一定要重视渲染结果不匹配的警告

P.S.具体见diffHydratedProperties,代码量较多,这里不再展开

组件渲染流程

render一样,hydrate也会执行完整的生命周期(包括在服务端执行过的前置生命周期):

// 创建组件实例
var instance = new ctor(props, context);
// 执行前置生命周期函数
// ...getDerivedStateFromProps
// ...componentWillMount
// ...UNSAFE_componentWillMount

// render
nextChildren = instance.render();

// componentDidMount
instance.componentDidMount();

所以,单从客户端渲染性能上来看,hydraterender的实际工作量相当,只是省去了创建 DOM 节点、设置初始属性值等工作

至此,React SSR 的下层实现全都浮出水面了

参考资料

有所得、有所惑,真好

关注「前端向后」微信公众号,你将收获一系列「用原创」的高质量技术文章,主题包括但不限于前端、Node.js以及服务端技术

本文首发于 ayqy.net ,原文链接:http://www.ayqy.net/blog/reac...

查看原文

赞 0 收藏 0 评论 0

前端向后 发布了文章 · 2020-11-19

精通 React SSR 之 API 篇

写在前面

React 提供的 SSR API 分为两部分,一部分面向服务端(react-dom/server),另一部分仍在客户端执行(react-dom

<img data-original="http://cdn.ayqy.net/data/home/qxu1001840309/htdocs/cms/wordpress/wp-content/uploads/2020/11/1_VG33xLBOqcpfctgiyh0jtA.png" alt="react ssr" width="625" height="446" class="size-large wp-image-2317" />

一.ReactDOMServer

ReactDOMServer相关 API 能够在服务端将 React 组件渲染成静态的(HTML)标签

The ReactDOMServer object enables you to render components to static markup.

把组件树渲染成对应 HTML 标签的工作在浏览器环境也能完成,因此,面向服务端的 React DOM API 也分为两类:

renderToString

ReactDOMServer.renderToString(element)

最基础的 SSR API,输入 React 组件(准确来说是ReactElement),输出 HTML 字符串。之后由客户端 hydrate API 对服务端返回的视图结构附加上交互行为,完成页面渲染:

If you call ReactDOM.hydrate() on a node that already has this server-rendered markup, React will preserve it and only attach event handlers.

renderToStaticMarkup

ReactDOMServer.renderToStaticMarkup(element)

renderToString类似,区别在于 API 设计上,renderToStaticMarkup只用于纯展示(没有事件交互,不需要 hydrate)的场景

This is useful if you want to use React as a simple static page generator, as stripping away the extra attributes can save some bytes. If you plan to use React on the client to make the markup interactive, do not use this method. Instead, use renderToString on the server and ReactDOM.hydrate() on the client.

因此renderToStaticMarkup只生成干净的 HTML,不带额外的 DOM 属性(如data-reactroot),响应体积上有些微的优势

之所以说体积优势些微,是因为在 React 16 之前,SSR 采用的是基于字符串校验和(string checksum)的 HTML 节点复用方式,字对字地严格校验一致性,一旦发现不匹配就完全丢弃服务端渲染结果,在客户端重新渲染:

If for any reason there’s a mismatch, React raises a warning in development mode and replaces the entire tree of server-generated markup with HTML that has been generated on the client.

生成了大量的额外属性:

// renderToString
<div data-reactroot="" data-reactid="1"
    data-react-checksum="122239856">
  <!-- react-text: 2 -->This is some <!-- /react-text -->
  <span data-reactid="3">server-generated</span>
  <!-- react-text: 4--> <!-- /react-text -->
  <span data-reactid="5">HTML.</span>
</div>

这时候renderToStaticMarkup生成干净清爽的 HTML 还有着不小的体积优势:

// renderToStaticMarkup
<div data-reactroot="">
  This is some <span>server-generated</span> <span>HTML.</span>
</div>

React 16 改用单节点校验来复用(服务端返回的)HTML 节点,不再生成data-reactiddata-react-checksum等体积占用大户,两个 API 渲染结果的体积差异变得微乎其微。例如,对于 React 组件:

class MyComponent extends React.Component {
  state = {
      title: 'Welcome to React SSR!',
    };
  }

  render() {
    return (
      <div>
        <h1 className="here">
          {this.state.title} Hello There!
        </h1>
      </div>
    );
  }
}

二者的渲染结果分别为:

// renderToString
<div data-reactroot=""><h1 class="here">Welcome to React SSR!<!-- --> Hello There!</h1></div>

// renderToStaticMarkup
<div><h1 class="here">Welcome to React SSR! Hello There!</h1></div>

也就是说,目前(2020/11/8,React 17.0.1)renderToStaticMarkuprenderToString的实际差异主要在于:

  • renderToStaticMarkup不生成data-reactroot
  • renderToStaticMarkup不在相邻文本节点之间生成<!-- -->(相当于合并了文本节点,不考虑节点复用,算是针对静态渲染的额外优化措施)

renderToNodeStream

ReactDOMServer.renderToNodeStream(element)

对应于renderToString的 Stream API,将renderToString生成的 HTML 字符串以Node.js Readable stream形式返回

P.S.默认返回utf-8 编码的字节流,其它编码格式需自行转换

P.S.该 API 的实现依赖Node.js 的 Stream 特性,所以不能在浏览器环境使用

renderToStaticNodeStream

ReactDOMServer.renderToStaticNodeStream(element)

对应于renderToStaticMarkup的 Stream API,将renderToStaticMarkup生成的干净 HTML 字符串以Node.js Readable stream形式返回

P.S.同样按 utf-8 编码,并且不能在浏览器环境使用

二.ReactDOM

hydrate()

ReactDOM.hydrate(element, container[, callback])

与常用的render()函数签名完全一致:

ReactDOM.render(element, container[, callback])

hydrate()配合 SSR 使用,与render()的区别在于渲染过程中能够复用服务端返回的现有 HTML 节点,只为其附加交互行为(事件监听等),并不重新创建 DOM 节点:

React will attempt to attach event listeners to the existing markup.

需要注意的是,服务端返回的 HTML 与客户端渲染结果不一致时,出于性能考虑,hydrate()并不纠正除文本节点外的 SSR 渲染结果,而是将错就错

There are no guarantees that attribute differences will be patched up in case of mismatches. This is important for performance reasons because in most apps, mismatches are rare, and so validating all markup would be prohibitively expensive.

只在development模式下对这些不一致的问题报 Warning,因此必须重视 SSR HydrationWarning,要当 Error 逐个解决:

This performance optimization means that you will need to make extra sure that you fix any markup mismatch warnings you see in your app in development mode.

特殊的,对于意料之中的不一致问题,例如时间戳,可通过suppressHydrationWarning={true}属性显式忽略该元素的 HydrationWarning(只是忽略警告,并不纠错,所以仍保留服务端渲染结果)。如果非要在服务端和客户端分别渲染不同的内容,建议先保证首次渲染内容一致,再通过更新来完成(当然,性能会稍差一点),例如:

class MyComponent extends React.Component {
  state = {
    isClient: false
  }

  render() {
    return this.state.isClient ? '渲染...客户端内容' : '渲染...服务端内容';
  }

  componentDidMount() {
    this.setState({
      isClient: true
    });
  }
}

三.SSR 相关的 API 限制

大部分生命周期函数在服务端都不执行

SSR 模式下,服务端只执行 3 个生命周期函数

  • constructor
  • getDerivedStateFromProps
  • render

其余任何生命周期在服务端都不执行,包括getDerivedStateFromErrorcomponentDidCatch等错误处理 API

<img data-original="http://cdn.ayqy.net/data/home/qxu1001840309/htdocs/cms/wordpress/wp-content/uploads/2020/11/96C4B5DD-20B3-43F9-A8C6-9B220EB2C0A5.png" alt="react ssr lifecycle" width="625" height="355" class="size-large wp-image-2319" />

P.S.已经废弃的componentWillMountUNSAFE_componentWillMountgetDerivedStateFromPropsgetSnapshotBeforeUpdate互斥,若存在后一组新 API 中的任意一个,就不会调用前两个旧 API

不支持 Error Boundary 和 Portal

With streaming rendering it's impossible to "call back" markup that has already been sent, and we opted to keep renderToString and renderToNodeStream's output identical.

为了支持流式渲染,同时保持 String API 与 Stream API 输出内容的一致性,牺牲了会引发渲染回溯的两大特性

  • Error Boundary:能够捕获子孙组件的运行时错误,并渲染一个降级 UI
  • Portal:能够将组件渲染到指定的任意 DOM 节点上,同时保留事件按组件层级冒泡

很容易理解,流式边渲染边响应,无法(回溯回去)修改已经发出去的内容,所以其它类似的场景也不支持,比如渲染过程中动态往head里插个stylescript标签

P.S.关于 SSR Error Boundary 的更多讨论,见componentDidCatch doesn't work in React 16's renderToString

参考资料

有所得、有所惑,真好

关注「前端向后」微信公众号,你将收获一系列「用原创」的高质量技术文章,主题包括但不限于前端、Node.js以及服务端技术

本文首发于 ayqy.net ,原文链接:http://www.ayqy.net/blog/reac...

查看原文

赞 0 收藏 0 评论 0

前端向后 发布了文章 · 2020-11-11

SSR 与当年的 JSP、PHP 有什么区别?

写在前面

SSR(Server-Side Rendering)并不是什么新奇的概念,前后端分层之前很长的一段时间里都是以服务端渲染为主(JSP、PHP),在服务端生成完整的 HTML 页面

(摘自前端渲染模式的探索

也就是说,历经 SSR 到 CSR 的大变革之后,如今又从 CSR 出发去探索 SSR 的可能性……似乎兜兜转转又回到了起点,在这之间发生了什么?如今的 SSR 与当年的 JSP、PHP 又有什么区别?

一.SSR 大行其道

回到论坛、博客、聊天室仍旧火热的年代,行业最佳实践是基于 JSP、PHP、ASP/ASP.NET 的动态网站

以 PHP 为例:

<?php if ( count( $_POST ) ): ?>
<?php include WTG_INCPATH . '/wechat_item_template.php' ?>
<div style="...">

  <div id="wechat-post" class="wechat-post" style="...">
    <div class="item" id="item-list">
    <?php
        $order = 1;
        foreach ( $_POST['posts'] as $wechat_item_id ) {
        echo generate_item_list( $wechat_item_id, $order );
            $order++;
        }
    ?>
    </div>
    <?php
    $order = 1;
    foreach ( $_POST['posts'] as $wechat_item_id ) {
    echo generate_item_html( $wechat_item_id, $order );
    $order++;
    }
    ?>
    <fieldset style="...">
      <section style="...">
        <p style="...">如果心中仍有疑问,请查看原文并留下评论噢。(<span style="font-size:0.8em; font-weight:600">特别要紧的问题,可以直接微信联系 ayqywx</span> )</p>
      </section>
    </fieldset>
</div>
<script>
    function refineStyle () {
        var post = document.getElementById('wechat-post');
        // ul ol li
        var uls = post.getElementsByTagName('ul');
        for (var i = uls.length - 1; i >= 0; i--) {
            uls[i].style.cssText = 'padding: 0; margin-left: 1.8em; margin-bottom: 1em; margin-top: -1em; list-style-type: disc;';
            uls[i].removeAttribute('class');
        };
    }

    document.addEventListener('DOMContentLoaded', function() {
        refineStyle();
    });
  </script>
</div>

<?php endif ?>

(摘自ayqy/wechat_subscribers,一款用来自动生成微信公众平台图文消息的 WordPress 插件)

这一时期网页内容完全由服务端渲染,客户端(浏览器)接收到的是融合了服务数据的 HTML,以及少量内联的(表单)交互逻辑和样式规则,支撑着早期大量动态网站的正是这种纯 SSR 模式

但随着技术实践的深入,这种模式逐渐暴露出了一些问题:

  • 性能差:每一个请求过来都要重新执行一遍数据逻辑和视图逻辑,动态生成 HTML,即便其中很大一部分内容是相同的
  • 机器成本高:Tomcat/Apache 等应用服务器的并发处理能力远不及nginx之类的 Web 服务器,因此需要部署更多的机器
  • 开发/维护难:前后端代码掺杂在一起,人员协作是个问题,并且修改维护要十分谨慎(标签结构容易被破坏)

面对这些问题,两个思路逐渐变得清晰起来,动静分离前后端分层,前者解决性能和机器成本的问题,后者解决开发/维护的问题

二.动静分离

为了充分利用 Web 服务器的静态资源处理优势,同时减轻应用服务器的负担,将资源分为两类:

  • 静态资源:图片、CSS、JS 等公用的,与具体用户无关的资源
  • 动态资源:应用逻辑、数据操作等与具体用户密切相关的资源

两种资源分开部署,把静态资源部署至 Web 服务器或 CDN,应用服务器只部署动态资源。如此这般,静态资源响应更快了(浏览器缓存、CDN 加速),应用服务器压力更小了,皆大欢喜

然而,视图逻辑却被我们漏掉了,HTML 算作静态资源还是动态资源?

前后端分层就是为了回答这个问题

三.前后端分层

视图逻辑的特殊之处在于:

  • 与数据密切相关
  • 服务端与客户端均可承载视图逻辑

也就是说,HTML 视图结构的创建和维护工作,可以由服务端完成,也可以在客户端完成,都依赖服务数据。但与服务端相比,客户端环境有一些优势:

  • 无需刷新(重新请求页面)即可更新视图
  • 免费的计算资源

因此,视图逻辑划分到了客户端(即 CSR),以数据接口为界,分成前后端两层

  • 后端:提供数据及数据操作支持
  • 前端:负责数据的呈现和交互功能

自此,前后端各司其职,前端致力于用户体验的提升,后端专注业务领域,并行迭代,(不涉及接口变化时)互不影响

四.CSR 如日中天

前后端分层之后,进入了 CSR 的黄金时代,探索出了功能插件、UI 库、框架、组件等多种代码复用方案,最终形成了繁荣的组件生态

组件化的开发方式之下,纯 CSR 模式日益盛行:

<!DOCTYPE html>
<html>
<head>
    <title>My Awesome Web App</title>
    <meta charset="utf-8">
</head>
<body>
    <div id="app"></div>
    <script data-original="bundle.js"></script>
</body>
</html>

这种模式下,几乎所有的页面内容都由客户端动态渲染而来,包括创建视图、请求数据、融合数据与模版、交互功能在内的所有工作,都交由一套数据驱动的组件渲染机制来全权管理,而不必再关注组件之下的 DOM 结构维护等工作,有效提高了前端的生产效率。但一些问题也随之而来:

  • 在组件树首次渲染完之前,页面上无法展示任何内容,包括 loading
  • 数据请求必须等到所属组件开始渲染才能发出去

这些问题的根源在于目前的组件渲染流程是同步阻塞的,对首屏性能提出了挑战:

  • 低端设备上 JS 执行效率低,白屏时间长
  • 弱网环境下数据返回慢,loading 时间长

CSR 虽然利用了用户设备的计算资源,但同时也受其性能、网络环境等不可控因素的制约。于是,大家又重新将目光聚集到了 SSR

五.SSR 东山再起

SSR 模式下,首屏内容在服务端生成,客户端收到响应 HTML 后能够直接呈现内容,而无需等到组件树渲染完毕

虽然核心思想都是在服务端完成页面渲染工作,但如今的 SSR 与先前大不相同,体现在:

  • 出发点:为了更快、更稳定地呈现出首屏内容
  • 成熟度:建立在前端成熟的组件体系、模块生态之上,基于 Node.js 的同构方案成为最佳实践
  • 独立性:仍然保持着前后端分层,不与业务领域的应用服务强耦合

也就是说,如今的 SSR 是为了解决前端层的问题,结合 CSR 优化内容加载体验,是在 CSR 多年积淀之上的扩展,与现有的前端技术生态保持着良好的相容性。而当年的 SSR 更多地是为了实现功能,解决温饱问题

再看当年 SSR 面临的几个问题:

  • 性能差:每一个请求过来都要重新执行一遍数据逻辑和视图逻辑,动态生成 HTML,即便其中很大一部分内容是相同的
  • 机器成本高:Tomcat/Apache 等应用服务器的并发处理能力远不及nginx之类的 Web 服务器,因此需要部署更多的机器
  • 开发/维护难:前后端代码掺杂在一起,人员协作是个问题,并且修改维护要十分谨慎(标签结构容易被破坏)

引入 SSR 之后这些问题卷土重来,但这些年的技术发展为解决这些问题提供了新的思路:

  • 实时渲染的性能问题:动静分离的思路仍然适用,例如Static Generation
  • 服务器资源成本问题:云计算的发展有望大幅降低机器成本,例如Node FaaS
  • SSR 部分与 CSR 部分的开发/维护问题:同构为解决开发/维护难题提供了一种新思路(之前的思路是前后端分层,但这一次分不开了),维护同一份代码,跑在不同的运行环境输出不同形式的目标产物

其中,Static Generation(也叫 SSG,Static Site Generation)是指在编译时生成静态 HTML(可部署至 CDN),避免实时渲染的性能开销:

Static Generation (Recommended): The HTML is generated at build time and will be reused on each request.

但并非所有页面都能在编译时静态生成,一种可行的实践方案是将 SSR 与 Static Generation 结合起来,只对内容依赖个性化数据、或者频繁更新的页面走 SSR,其余场景都走 Static Generation

You should ask yourself: "Can I pre-render this page ahead of a user's request?" If the answer is yes, then you should choose Static Generation.

至此,沉寂多年的 SSR 又焕发出了新的活力

参考资料

有所得、有所惑,真好

关注「前端向后」微信公众号,你将收获一系列「用原创」的高质量技术文章,主题包括但不限于前端、Node.js以及服务端技术

本文首发于 ayqy.net ,原文链接:http://www.ayqy.net/blog/dife...

查看原文

赞 0 收藏 0 评论 0

前端向后 发布了文章 · 2020-11-04

2020 SSR落地开花的三大机遇

写在前面

上篇SSR 的利与弊列举了 SSR 渲染模式的 6 大难题:

  • 难题 1:如何利用存量 CSR 代码实现同构
  • 难题 2:服务的稳定性和性能要求
  • 难题 3:配套设施的建设
  • 难题 4:钱的问题
  • 难题 5:hydration 的性能损耗
  • 难题 6:数据请求

这些问题是 SSR 一直以来远不如 CSR 应用广泛的主要原因,但时至今日,Serverless、low-code、4G/5G 网络环境三大机遇让 SSR 出现了新的转机,落地开花正当时

第一大机遇:Serverless

无服务器计算(serverless computing)将服务器相关的配置管理工作统统交给云供应商去做,以减轻用户管理云资源的负担

对云计算用户而言,Serverless 服务能够(自动)弹性伸缩而无需显式预配资源,不仅免去了云资源的管理负担,还能够按使用情况计费,这一特点在很大程度上解决了“难题 4:钱的问题”:

引入 SSR 渲染服务,实际上是在网络结构上加了一层节点,而大流量所过之处,每一层都是钱

将组件渲染逻辑从客户端改到服务器执行,势必会增加成本,但有望通过 Serverless 将个中成本降到最低

另一方面,Serverless Computing的关键是 FaaS(Function as a Service),由云函数提供常规计算能力

直接运行后端代码,而无需考虑服务器等计算资源以及服务的扩展性、稳定性等问题,甚至连日志、监控、报警等配套设施也都开箱即用

也就是说,喂给 FaaS 一个 JavaScript 函数,就能上线一个高可用的服务,无需操心如何承载大流量(几万 QPS)、如何保障服务稳定可靠……听起来有些跨时代是么,实际上,AWS Lambda、阿里云 FC、腾讯云 SCF 都已经是成熟的商业产品了,甚至能够免费试用

无状态的模板渲染工作尤其适合用云函数(输入 React/Vue 组件,输出 HTML)来完成,“难题 2:服务的稳定性和性能要求”最关键的后端专业性问题迎刃而解,SSR 面临的技术难题从一个高可用的组件渲染服务缩小到了一个 JavaScript 函数中

与客户端程序相比,服务端程序对稳定性和性能的要求严苛得多,例如:

  • 稳定性:异常崩溃、死循环(由前端人员自行解决)
  • 性能:内存/CPU 资源占用(由 FaaS 基础设施解决)、响应速度(网络传输距离等都要考虑在内)

如何应对大流量/高并发,如何识别故障,如何降级/快速恢复(由 FaaS 基础设施解决),哪些环节需要加缓存,缓存如何更新……

FaaS 基础设施解决了大部分的性能问题和可用性问题,函数内的稳定性问题可通过纯前端手段解决,至于剩下的响应速度、缓存/缓存更新问题,则需要引入另一个云计算概念——边缘计算

边缘计算

所谓的边缘计算,就是将计算和数据存储分布到离用户更近的(CDN)节点(或者叫边缘服务器,Edge server)上,节省带宽的同时更快响应用户请求:

Edge computing is a distributed computing paradigm that brings computation and data storage closer to the location where it is needed, to improve response times and save bandwidth.

(摘自Edge computing

像传统 CDN 通过缩短静态内容与最终用户之间的物理距离来加速资源访问,同时减少了应用服务器的负载一样,支持边缘计算的 CDN 允许将云函数部署到边缘节点中,加速服务响应,同时依托 CDN 轻松控制缓存策略,甚至能够实现动静分离的边缘流式渲染(ESR)

P.S.基于边缘计算的 SSR 的更多信息,见前端性能优化:当页面渲染遇上边缘计算

第二大机遇:low-code

如果说 FaaS 解决了 SSR 落地最核心的服务可用性问题,给 SSR 插上了双翼,那么low-code则是让 SSR 得以冲向天际的助飞跑道

因为low-code 几乎解决了其余的所有难题

  • 难题 1:如何利用存量 CSR 代码实现同构
  • 难题 2:服务的稳定性和性能要求
  • 难题 3:配套设施的建设
  • 难题 4:钱的问题
  • 难题 5:hydration 的性能损耗
  • 难题 6:数据请求

源码开发模式下难以解决的问题,在 low-code 模式下有了不同维度的解法,就像是通过几何方法来解决代数问题

难题 1:如何利用存量 CSR 代码实现同构

要让现有的 CSR 代码在服务端跑起来,先要解决诸多问题,例如:

  • 客户端依赖:分为 API 依赖和数据依赖两种,比如window/document之类的 JS API、设备相关数据信息(屏幕宽高、字体大小等)
  • 生命周期差异:例如 React 中,componentDidMount在服务端不执行
  • 异步操作不执行:服务端组件渲染过程是同步的,setTimeoutPromise之类的都等不了
  • 依赖库的适配:React、Redux、Dva 等等,甚至还有第三方库等不确定能否跑在 universal 环境,是否需要跨环境共享状态,以状态管理层为例,SSR 要求其 store 必须是可序列化的
  • 两边共享状态:每一份需要共享的状态都要考虑(服务端)如何传递、(客户端)如何接收

首先,low-code 模式不同于源码开发,现有的 CSR 代码无法直接迁移到 low-code 平台上来,其次,low-code 配置化的开发模式提供了天然的细粒度逻辑拆分和完整的精细控制力,体现在:

  • 细粒度逻辑拆分:各个生命周期函数独立配置
  • 完整的精细控制力:依赖库、生命周期、异步操作、共享状态严格受控,low-code 平台全权控制所填代码的编译时、运行时环境

客户端依赖虽无法消除,但能够像函数式编程中的副作用一样管控起来,比如将其约束到特定的生命周期函数(componentDidMount)中,使之仅在客户端执行,避免影响服务端。生命周期的差异可通过 low-code 平台让用户产生强感知,比如在编辑、预览等环节强化差异。对于不支持的异步操作,可在编辑阶段进行校验并提示。至于依赖库和状态共享方式,low-code 平台能够全权控制,将其约束到支持范围内

总之,low-code 轻松解决了源码开发模式下棘手的如何约束写法、如何管控不确定性的问题

难题 3:配套设施的建设

SSR 最核心的部分是渲染服务,但除此之外还要考虑:

  • 本地开发套件(校验 + 构建 + 预览/HMR + 调试)
  • 发布流程(版本管理)

一整套的工程设施,在 SSR 模式下都需要重新考虑

这些配套设施是 SSR 要解决的问题,low-code 也面临同样的问题,因此,SSR 能够在一定程度上复用 low-code 提供的在线研发链路支持,只对其部分环节进行扩展,降低配套设施建设的成本

难题 5:hydration 的性能损耗

组件作为一层抽象,在提供模块化开发、组件复用等工程价值的同时,也带来了一些问题。典型的,交互逻辑与组件渲染机制绑定在了一起,这是 SSR 需要 hydration 的根本原因

客户端接到 SSR 响应之后,为了支持(基于 JavaScript 的)交互功能,仍然需要创建出组件树,与 SSR 渲染的 HTML 关联起来,并绑定相关的 DOM 事件,让页面变得可交互,这个过程称为 hydration

也就是说,只要仍然依赖组件这层抽象,hydration 的性能损耗就无可避免。在源码开发模式下,组件无可替代,因为没有与之等价的抽象描述形式。然而,在 low-code 模式下,其输出产物(配置数据)也是一种抽象描述形式,如果能够具有与组件同等的表达力,就完全有可能去掉组件这层抽象,不必再背负 hydration 的性能损耗

另一方面,对于无交互(纯静态展示)、弱交互(静态展示带埋点/跳转)的偏静态场景,low-code 平台也能准确地识别出来,避免不必要的 hydration

难题 6:数据请求

服务端同步渲染要求先发请求,拿到数据后才开始渲染组件,那么面临 3 个问题:

  • 数据依赖要从业务组件中剥离出来
  • 缺失客户端公参(包括 cookie 等客户端会默认带上的 header 信息)
  • 两边数据协议不同:服务端可能有更高效的通信方式,比如 RPC

low-code 开发模式下,数据依赖以配置化的形式录入,天然剥离,客户端公参、数据协议等均可通过 low-code 平台来配置,比如配 HTTP、RPC 两套协议,按环境自动选用

第三大机遇:4G/5G 网络环境

移动时代早期,离线 H5 是业界最佳实践,因为在线页面意味着秒级的加载时间,离线页面有着巨大的加载速度优势

但随着网络环境的发展,离线页面的加载速度优势已经不再是决定性因素(小程序的大爆发足以说明问题),在线页面的动态化特性备受关注,(SSR 无能为力的)离线场景越来越少,SSR 的用武之地越来越多

参考资料

有所得、有所惑,真好

关注「前端向后」微信公众号,你将收获一系列「用原创」的高质量技术文章,主题包括但不限于前端、Node.js以及服务端技术

本文首发于 ayqy.net ,原文链接:http://www.ayqy.net/blog/2020...

查看原文

赞 0 收藏 0 评论 0

前端向后 发布了文章 · 2020-10-30

该不该上 SSR,上了有什么好处?

一.SSR 简介

image

SSR(Server-Side Rendering)并不是什么新奇的概念,前后端分层之前很长的一段时间里都是以服务端渲染为主(JSP、PHP),在服务端生成完整的 HTML 页面

摘自前端渲染模式的探索

之所以要在服务端完成组件渲染工作,是因为有性能与可访问性两大优势

二.2 大优势

性能

与 CSR(Client-side rendering)模式相比,SSR 的性能优势体现在 2 方面:

  • 网络链路

    • 省去了客户端二次请求数据的网络传输开销
    • 服务端的网络环境要优于客户端,内部服务器之间通信路径也更短
  • 内容呈现

    • 首屏加载时间(FCP)更快
    • 浏览器内容解析优化机制能够发挥作用

网络链路上,由服务端发出接口请求,将返回数据随 HTML 响应内容一次性传递到客户端,比 CSR 二次请求更快。并且服务端网络传输速度更快(可以有更大带宽)、通信路径更短(可以同机房部署)、通信效率也更高(可以走 RPC)

内容呈现方面,CSR 的 HTML 大多是个空壳儿:

<!DOCTYPE html>
<html>
<head>
    <title>My Awesome Web App</title>
    <meta charset="utf-8">
</head>
<body>
    <div id="app"></div>
    <script data-original="bundle.js"></script>
</body>
</html>

客户端拿到这种 HTML 只能立即渲染出一页空白,二次请求的数据回来之后才能呈现出有意义的内容,而 SSR 返回的 HTML 是有内容(数据)的,客户端能够立刻渲染出有意义的首屏内容(First Contentful Paint)。同时,静态的 HTML 文档让流式文档解析(streaming document parsing)等浏览器优化机制也能发挥其作用

关键区别是 SSR 不依赖客户端环境,包括网络环境和设备性能,即使用户的网络情况很糟(弱网)、设备性能很差(廉价、老旧设备),服务端渲染同样能够保障与最优用户环境(Wi-Fi 网络、高端设备)下相近的内容加载体验

可访问性

可访问性(accessibility)从两方面理解:

  • 对人:古老、特殊的用户设备,比如禁用了 JavaScript
  • 对机器人:爬虫程序等,典型的,搜索引擎爬虫

前者一般不必太过在意,后者要关注两大“客户”:

  • 搜索引擎:SEO
  • 社交媒体:抓取页面内容展示缩略信息(比如 Twitter 卡片等)

对 PC 站点而言,保证搜索引擎能够正确索引、准确理解页面内容,有重要的商业价值(搜索结果靠前,曝光量更大)。移动端虽不必考虑搜索引擎爬取,但也有类似的社交分享需求,社交媒体会抓取目标页面中的图片等作为缩略信息

P.S.诚然,有些搜索引擎能够正确爬取重 CSR 的 SPA,但不是全部,并且一大批社交媒体大都只从响应 HTML 中提取部分内容作为缩略信息,动态渲染 HTML(部分)内容的需求真切存在

虽具有这些优势,但 SSR 却远不如 CSR 应用广泛,是因为 SSR 面临着 6 大难题

三.6 个难题

难题 1:如何利用存量 CSR 代码实现同构

为了降级、复用、降低迁移成本等目的,通常会采用一套 JavaScript 代码跨客户端、服务端运行的同构方式来实现 SSR,然而,要让现有的 CSR 代码在服务端跑起来,先要解决诸多问题,例如:

  • 客户端依赖:分为 API 依赖和数据依赖两种,比如window/document之类的 JS API、设备相关数据信息(屏幕宽高、字体大小等)
  • 生命周期差异:例如 React 中,componentDidMount在服务端不执行
  • 异步操作不执行:服务端组件渲染过程是同步的,setTimeoutPromise之类的都等不了
  • 依赖库的适配:React、Redux、Dva 等等,甚至还有第三方库等不确定能否跑在 universal 环境,是否需要跨环境共享状态,以状态管理层为例,SSR 要求其 store 必须是可序列化的
  • 两边共享状态:每一份需要共享的状态都要考虑(服务端)如何传递、(客户端)如何接收

难题 2:服务的稳定性和性能要求

与客户端程序相比,服务端程序对稳定性和性能的要求严苛得多,例如:

  • 稳定性:异常崩溃、死循环
  • 性能:内存/CPU 资源占用、响应速度(网络传输距离等都要考虑在内)

因此面临后端专业性问题,Demo 级的 SSR 可能并不难,但高可用的 SSR 服务却绝非易事,如何应对大流量/高并发,如何识别故障,如何降级/快速恢复,哪些环节需要加缓存,缓存如何更新……

难题 3:配套设施的建设

SSR 最核心的部分是渲染服务,但除此之外还要考虑:

  • 本地开发套件(校验 + 构建 + 预览/HMR + 调试)
  • 发布流程(版本管理)

一整套的工程设施,在 SSR 模式下都需要重新考虑

难题 4:钱的问题

引入 SSR 渲染服务,实际上实在网络结构上加了一层节点,而大流量所过之处,每一层都是钱

Most importantly, SSR React apps cost a lot more in terms of resources since you need to keep a Node server up and running.

将组件渲染逻辑从客户端改到服务器执行,计算资源的成本必须考虑在内

难题 5:hydration 的性能损耗

客户端接到 SSR 响应之后,为了支持(基于 JavaScript 的)交互功能,仍然需要创建出组件树,与 SSR 渲染的 HTML 关联起来,并绑定相关的 DOM 事件,让页面变得可交互,这个过程称为 hydration

hydration 所需加载、执行的 JavaScript 代码不见得比 CSR 模式少多少,这部分工作在客户端执行,受限于用户设备的性能,在较差的设备下可能会造成可感知的不可交互时间:

  • CSR:可交互但是没有数据(还在异步请求数据,可能会持续很长)
  • SSR:有数据但是不可交互(拉到 JS 后开始 hydrate 的过程,能看到内容但是不可交互,一般不会持续很长)

富交互的场景下,后者不一定比前者用户体验更好

难题 6:数据请求

服务端同步渲染要求先发请求,拿到数据后才开始渲染组件,那么面临 3 个问题:

  • 数据依赖要从业务组件中剥离出来
  • 缺失客户端公参(包括 cookie 等客户端会默认带上的 header 信息)
  • 两边数据协议不同:服务端可能有更高效的通信方式,比如 RPC

目前主流的 CSR 模式下,数据依赖与业务组件存在紧耦合,要由服务端发起的数据请求全都掺杂在组件生命周期函数中,剥离数据依赖意味着需要同时改造 CSR 代码。公参、数据协议等差异对代码复用、可维护性也提出了一些新的挑战

四.应用场景

无论首屏加载性能还是可访问性,都是对内容密集型页面才有意义,而对于交互密集型的页面,SSR 所能提前渲染的内容不多,对用户意义不大,SEO 的必要性也值得商榷。因此,SSR 适用于偏静态的内容展示场景,典型的,商品详情、攻略、文章等图文混排的场景

另一方面,不一定非要 100% SSR,渲染特定页面,甚至只渲染个页面框架也是不错的应用:

"Application Shell" is an excellent concept. But sometimes, we might need to render a part of the page in the server. It could be the header with user info. In such cases, you need server-side rendering.

参考资料

有所得、有所惑,真好

关注「前端向后」微信公众号,你将收获一系列「用原创」的高质量技术文章,主题包括但不限于前端、Node.js以及服务端技术

本文首发于 ayqy.net ,原文链接:http://www.ayqy.net/blog/ssr-...

查看原文

赞 0 收藏 0 评论 0

前端向后 发布了文章 · 2020-10-21

假如 Web 当初不支持动态化

楔子

Web 生而具有极其灵活的动态化基础能力,诸如:

  • 动态插入script标签执行任意脚本逻辑
  • 动态插入style标签引入任何 CSS 样式规则
  • 通过iframe标签嵌入整站
  • 以上标签均可直接加载网络资源
  • 承载这些内容的 Web 页面部署在远程服务器,可随时动态更新,并且能立即生效

一直以来的探索和实践似乎只是在不断地发掘动态化能力的工程价值,为其寻找更合适的应用场景,比如早期的frameset,如今的微前端/微应用

而移动端正好相反,生而具有许多灵活性限制:

  • 原生不支持动态执行逻辑代码
  • 构成移动应用程序的关键资源大都要打入安装包中(动态库例外)
  • 应用程序安装在用户设备上,安装包更新需经应用商店审核,用户重新安装才能生效

移动业务的发展不断地对动态化能力提出更高的要求,但苦于缺少动态化的基础能力,所以一直在探索更灵活的技术方案,像早期的热修复/热更新,到如今的小程序

实际上,二者在动态化技术能力上所要解决的工程问题是一致的,比如动态加载依赖库、视图组件、甚至整个应用。所以不妨开个脑洞,假定 Web 不支持动态化,以 Native 的业务诉求来推演 Web 动态化技术的发展轨迹

伊始:原生 WebAssembly

0061 736d 0100 0000 0187 8080 8000 0160
027f 7f01 7f03 8280 8080 0001 0004 8480
8080 0001 7000 0005 8380 8080 0001 0001
0681 8080 8000 0007 9080 8080 0002 066d
656d 6f72 7902 0003 6763 6400 000a ab80
8080 0001 a580 8080 0001 017f 0240 2000
450d 0003 4020 0120 0022 026f 2100 2002
2101 2000 0d00 0b20 020f 0b20 010b

从前,Web 应用程序只能被打包成这种wasm的二进制格式,发布到各大浏览器应用商店。期间,不仅要等待数天的审核,通过之后还要等用户主动安装更新,等到新版本真正“生效”(覆盖大多数用户),可能已经是数月之后了

版本更迭慢,无论是战略性的重要功能还是十万火急的问题修复,都无法及时触达用户。即便线上着火了,最快速的救火方案也要几天甚至几周之后才能起到作用

为了能够更快地修复问题、降低风险,热修复方案的探索就此展开

浪花:为热修复引入脚本语言 JavaScript

热修复意味着要加载并运行(安装包之外的)逻辑代码,所以有人直接从 WebAssembly 模块加载机制入手,研究出了一些 Hook 方案,能够动态地换掉某些模块/文件

也有人沿着这个方向走得更远,权衡时效性、性能、兼容性与稳定性,通过编译插桩、工程配套设施、运行时框架等手段解决了模块依赖、版本管理、差量更新等问题,将应用程序的各个功能模块插件化

还有人另辟蹊径,引入轻量级的脚本语言运行时(如 JavaScript 引擎),并在浏览器原生 WebAssembly 与 JavaScript 世界之间架起一座桥梁,允许通过 JavaScript 调用原生的系统平台能力,从而扩展出了动态化的基础能力

动态化漾起了一道波纹,紧接着是呼啸而来的动态更新浪潮

海啸:基于 JavaScript 的动态更新

往动态化方向迈出第一步之后,离全面动态化的大好前景也就一步之遥了:

Any application that can be written in JavaScript, will eventually be written in JavaScript. —— Jeff Atwood

(摘自The Principle of Least Power

全面动态化意味着要:

  • 将应用程序中所有能够动态化的部分全都迁由 JavaScript 实现
  • 将庞大的 JavaScript 代码按功能模块组织起来,并管理好功能模块之间的依赖关系

从而实现以功能模块为单位的快速迭代,相当于将热修复技术应用到问题修复之外的需求迭代上,既不用发版,免去了审核周期,也不需要等待用户主动安装,新功能得以动态发布并迅速覆盖到活跃用户

堤坝:容器概念形成

随着动态化程度的不断提升,JavaScript 在应用程序中的占比越来越高,最终仅剩余无法动态化(或没有必要动态化)的部分仍由 WebAssembly 实现,包括:

  • 系统平台能力桥接
  • 基础 UI 控件、交互能力
  • 视图层框架(历史栈管理、生命周期支持等)
  • 特定业务领域能力(例如多媒体内容生产、IM SDK 等)
  • 通信机制(广播、状态共享等)

这些部分形成了容器(原生外壳),相当于运行在浏览器中的一个动态化运行时,在容器圈定的能力范围内,业务能够充分利用动态优势,实现快速修复、快速发布、快速触达、快速迭代

但随容器概念一同出现的,除了赋能业务跑得更快之外,还有动态业务与容器之间的依赖问题:

  • 如何解除二者之间的强耦合,如路由、混合视图容器等场景?
  • 如何识别出二者之间的依赖关系?
  • 如何保障依赖关系是可控的,比如禁止将依赖新能力的动态业务发布到旧容器中?

通过工程配套设施将依赖管束起来之后,接下来的首要问题是想办法保证动态业务所依赖的底层容器的可靠性

边界:HTML、JavaScript、CSS 构成容器标准

隔离变化的惯用手段是加一层抽象,将变化的部分置于抽象层之下:

  • BOM API:对系统平台、视图层框架能力以及通信机制的抽象
  • Native Module API:对特定业务领域能力的抽象
  • DOM API:对基础视图渲染能力的抽象
  • JS API:对 JavaScript 运行时的抽象
  • CSS:对样式、布局能力的抽象
  • HTML:对基础 UI 控件、交互能力的抽象

抽象出的这些标准确立了稳固的容器边界,边界之内,动态业务能够肆意发挥,边界之下,容器同样能够不断精进、丰富容器能力,将边界拓宽。同时,具有标准定义的 API 能够以结构化的形式维护起来,对于开发体验大有裨益

云海:浏览器支持加载网络资源

另一方面,在标准化的过程中,一些动态化业务实践也沉淀到了容器之中,例如:

  • 动态脚本:script支持加载网络资源
  • 动态样式:style支持加载网络资源
  • 动态路由:浏览器支持直接通过 URL 载入、或通过iframe嵌入网络应用程序

虽然从热修复开始就能够从CDN拉取 JS 文件,运行时动态解释执行了,但容器标准不仅对这种方式提供了便捷支持,还将动态化的基础能力从逻辑扩大到了视图、样式、静态资源等等

至此,动态化最关键的基础能力已经完备了。迁至 JavaScript 的功能模块甚至能够进一步部署到云端,实现离线集成、在线托管两种模式的灵活切换

一色:同步、异步模式切换自如

完备的动态化基础能力解锁了许多新玩法,例如:

将业务模块(bundle)进一步拆分成功能模块(chunk),并将非核心模块异步出去,实现动态按需加载,例如第三方 JS SDK、jQuery 插件、以及分享/评论/城市选择等重磅组件

对于内容呈现的偏静态场景,还可以通过 SSR 在服务端完成(大部分)页面渲染工作,加快首屏内容展现

另一方面,Hydration、lazy 组件、Suspense 等运行时特性使得在线的动态部分能够与离线的非动态部分充分融合,实现更细粒度的业务动态化,让在线托管真正成为一种部署选项

与此同时,动态业务自身的组件化程度也在不断加深,前端开发的核心工作从页面、模块开发转向了组件、编排逻辑开发

流云:数据驱动的前端应用程序

组件体系趋向成熟之后,一个由来已久的概念终于彻底浮出水面——数据驱动

从前后端分层的数据协议,逐渐演变成数据驱动,这里的数据包括 3 部分:

  • 后端业务域数据
  • 前端状态数据
  • (基于后端业务域数据的)前端衍生数据

将这些数据填入业务组件,即可渲染出完整的功能模块(无论是在客户端还是服务端),再将其放置到视图容器中合适的坑位里,就完成了一次组件级的“发布”过程

这种模式涉及 5 个重要环节:

  • 业务数据(包括后端业务域数据和前端衍生数据)的生产
  • 业务组件(包括前端状态数据)的生产和维护
  • 组件的渲染(业务数据 + 业务组件 = 功能模块
  • 坑位的生产
  • 功能模块的投放

其中,业务组件、坑位是进一步动态化的关键,可分为 4 个阶段:

  • 一个萝卜一个坑:静态业务组件 + 静态坑位
  • 一个萝卜到处扔:静态业务组件 + 动态坑位
  • 多个萝卜轮番扔:动态业务组件 + 静态坑位
  • 多个萝卜到处扔:动态业务组件 + 动态坑位

要达到多个萝卜到处扔的组件级动态化终极目标,就要求能够动态发布业务组件、动态发布坑位

交融:动态业务组件 + 动态坑位

从端和云的视角来看,业务组件也可以看作数据(云)的一部分,相比之下坑位与端的关联更为紧密,而动态化的唯一手段就是将端侧的东西搬到云上去,所以要解决的关键问题是如何实现坑位的动态化

有 2 个思路:

  • 干掉坑位的概念:将坑位的概念从组件级扩展到页面级,一个页面容器(一个 URL)即一个坑位
  • 将坑位组件化:提供标准的坑位组件,就像iframe

页面是一种天然的动态坑位,可打开一个新的页面容器加载任意 URL

对于除页面之外的其它布局容器,如对话框、消息条、Banner 位、腰封等等,可以将坑位标准化成容器组件,与业务组件一并动态发布,将坑位的租赁关系维护在服务端,作为数据驱动的数据之一

至此,前后端分层的界限几经重新定义,终于迎来了 JSP/PHP 融合数据与模板的黄金年代……

参考资料

有所得、有所惑,真好

关注「前端向后」微信公众号,你将收获一系列「用原创」的高质量技术文章,主题包括但不限于前端、Node.js以及服务端技术

本文首发于 ayqy.net ,原文链接:http://www.ayqy.net/blog/%e5%...

查看原文

赞 0 收藏 0 评论 0

前端向后 发布了文章 · 2020-10-14

如何提升前端基建的效能价值?

写在前面

上一篇如何衡量工具平台的效能价值?推导出了一种度量模型,通过具体的数据指标来衡量效能价值,让内部工具/平台的价值也能看得见、说得清

那么,对于正在做或者将要做的工具平台,如何进一步提升其效能价值呢?

一.效能价值有哪些影响因素?

首先,工具的关键目标是解决实际问题

工具总是为解决问题而生的

选定目标问题之后,接着通过工具化、平台化等自动/半自动的手段来尝试解决,并通过效率和体验两方面的提升体现出解决方案的效能价值

效能价值 = 效率价值 * 体验因子

进一步细化:

工具效率 = 问题规模 / 操作时间
工具效率 = (不用该工具解决所需的)时间成本 / (用该工具解决所需的)时间成本

工具体验 = 易用程度 * 稳定程度

因此,工具的效能价值取决于 4 个因素:

  • 问题规模
  • 操作时间
  • 易用性
  • 稳定性

提升工具效能就是想办法增大分子、减小分母,即提升问题规模、易用性、稳定性,降低操作时间

二.如何提升问题规模?

对于选定的目标问题,其规模通常是固定的,所以关键在于如何选择目标价值最高的问题

问题的目标价值 = 目标用户量 * 需求频率 * 单次的价值

多数情况下,我们倾向于选择目标用户量更大的问题,因为解决一个普遍存在的问题要比解决只有小部分用户才会遇到的特殊问题更有意义

然而,需求频率与单价对目标价值的影响却不那么显而易见:

其中:

  • 首选高频高价:非常难得的需求,如果有,优先满足
  • 不做低频低价:此类需求不值得做
  • 高频低价、低频高价并重:大多数需求都是这两类,选择也都集中在这里

在高频低价与低频高价之间,产品经理的一般策略是:

高频抓用户,低频做利润

也就是说,前期先通过满足高频低价的需求获得大量用户,中后期再将低频高价的需求考虑进来

先利用高频低价的需求抓用户,因为高频场景和用户互动的机会多,而低价的轻决策场景可以降低用户进入门槛,容易拉新、引流;再用低频高价的需求做利润,因为单价高了,可以切分的蛋糕才大。之所以采取这样的先后次序,是因为必须有海量用户做基础,低频需求的总量才足够大。

三.如何降低操作时间?

当然,如果有明显的待优化项,应该尽快去做,先把工具自身的效率提升到相当高的水准,减少用户等待工具运转完成的时间

但如果工具本身在耗时上已经没有太大的优化空间,此时就需要将目光从局部的工具中移出来,放眼全局考虑整体优化

  • 面向过程的视角:流程上,能否减少一些中间环节,简化工作流
  • 面向对象的视角:模式上,能否减少参与其中的相关角色,减少人与工具、工具与工具、工具与人之间的交互,减少一些中间产物

流程上,甚至协作模式上的变革通常有机会颠覆先前解决问题的关键路径,绕过既有工具的效率瓶颈,从而大幅降低操作时间

四.如何提升易用性?

工具型产品的第一要义是用户会用,让用户至少会用,才能体现产品的价值

易用性要求产品功能尽可能地符合用户心智(至少要保证核心功能的易用性),简化交互,降低用户上手使用的学习成本:

从用户心智向产品功能做映射,极致的易用是符合直觉,上手即用

那么,首先要明确用户心智,做法非常简单:

告诉用户,这个工具能给你解决什么具体问题。

接着(在产品功能不那么符合直觉的阶段)先教会用户怎么用,功能引导、新手教程/视频、帮助文档等都是不错的方法,旨在提升易用性,让用户先用起来。同时根据用户真实反馈不断优化使用体验,缩小产品功能与用户心智之间的差距,使之最终符合直觉:

  • 心智负担小(学习成本低)
  • 交互友好
  • UI 美观
  • 核心功能流程顺畅

除了让产品功能向用户心智靠拢外,还有一种非常规思路是培养用户心智(即改变用户直觉,使之符合产品功能),多出现在颠覆式创新的场景,必须改变用户根深蒂固的直觉才能真正提高效率

五.如何提升稳定性?

从用户心智向产品性能做映射,极致的稳定是完全信任,从不怀疑工具会出问题

与易用性相比,稳定性是客观而明确的,单从技术角度就能在很大程度上确保稳定性,例如:

  • 降低 crash 率:持续关注 top 崩溃,及时修复影响范围较大的
  • 减少 bug 数:持续观察 bug 增长趋势,快速迭代修复,收敛功能性问题
  • 减少操作失败次数:记录失败操作,分析改善常见误操作,同时反向丰富功能

其中,值得注意是记录失败操作,以搜索功能为例,失败操作包括:

  • 搜索服务出错
  • 搜索无结果
  • 搜索结果与预期不符(结果没有帮助)

从技术上看,后两类并不属于操作失败,但同样值得关注,因为无结果的搜索通常意味着语义化/模糊搜索功能不够完善,或者相关内容有缺失,这些信息对于丰富产品功能很有帮助。同理,不符合用户预期的搜索结果也是一种有价值的负反馈,有助于发现问题,改善用户使用体验

六.如何提升用户量?

当工具的效率和体验都达标后,最关键的问题是如何提升用户量,放大工具的价值

与其它产品相比,工具型产品的难点在于:

  • 可替代性强
  • 用户不知道(有工具可以用)
  • 用户粘性差,容易流失

强的不可替代性是决定性因素,作为唯一选项自然不必考虑用户量的问题,例如小程序开发者工具

如果不具备强的不可替代性,就要通过其它手段来增加用户的替换成本,常用的策略有场景化运营、社区运营、内容运营等

场景化运营

将工具与使用场景紧密关联起来,培养用户的使用习惯:

做工具型产品一定要时刻追问用户在什么样的场景下会想到打开你的产品,这个具体场景就是一切运营的基础

围绕一个核心场景,充分满足关键需求,成为该场景下的最优解决方案,从而解决用户不知道的问题

另一方面,场景化的温馨提示有助于提升产品的温度,让用户感受到人性关怀,而不只是冷冰冰的工具

社区运营

加强产品与用户,以及用户与用户的联系,建立社区是提高用户粘性的有效手段,例如:

  • 运营一个群组:将冰冷的工具做成能够交流的“活人”,拉近产品与用户的距离
  • 增加社交功能:用户订阅产品更新,用户之间关注、评论、点赞等,增加用户的参与感和归属感

通过群组将产品的变化告知用户,这种持续的频繁正向反馈能够激发用户反馈问题的积极性,增强产品与用户的联系

社交化听起来与供内部使用的工具平台有些距离,实际上并不遥远。以前端工程为例,像公共组件/代码片段、Code Review、新手教程/API 文档等都可以有简单的社交功能(点赞、评论),看似细小,却有助于提升用户的参与度

内容运营

与社区一样,内容也是一种场景延伸,将工具产出的内容也作为工具的一部分,例如:

工具引导用户输出附加价值,从而提升工具的整体价值(工具 + 共享内容)。另一方面,用户将产生的内容分享给其它用户,也有助于提升自身的影响力,互相促进

参考资料

有所得、有所惑,真好

关注「前端向后」微信公众号,你将收获一系列「用原创」的高质量技术文章,主题包括但不限于前端、Node.js以及服务端技术

本文首发于 ayqy.net ,原文链接:http://www.ayqy.net/blog/%e5%...

查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 26 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2019-10-10
个人主页被 1.3k 人浏览