[開源] 一個通用的新手引導解決方案

本組件已開源,源碼可見:https://github.com/bytedance/guide

組件背景

不管是老用戶還是新用戶,在產品發佈新版本、有新功能上線、或是現有功能更新的場景下,都需要一定的指導。功能引導組件就是互聯網產品中的指示牌,它旨在帶領用戶參觀產品,幫助用戶熟悉新的界面、交互與功能。與 FAQs、產品介紹視頻、使用手冊、以及 UI 組件幫助信息不同的是,功能引導組件與產品 UI 融合爲一體,不會給用戶割裂的交互感受,並且不需要用戶主動進行觸發操作,就會展示在用戶眼前。

圖片比文字更加具象,以下是兩種典型的新手引導組件,你是不是一看就明白功能引導組件是什麼了呢?

功能簡介

分步引導

Guide 組件以分步引導爲核心,像指路牌一樣,一節一節地引導用戶從起點到終點。這種引導適用於交互流程較長的新功能,或是界面比較複雜的產品。它帶領用戶體驗了完整的操作鏈路,並快速地瞭解各個功能點的位置。

呈現方式

蒙層模式

顧名思義,蒙層引導是指在產品上用一個半透明的黑色進行遮罩,蒙層上方對界面進行高亮,旁邊配以彈窗進行講解。這種引導方式阻斷了用戶與界面的交互,讓用戶的注意力聚焦在所圈注的功能點上,不被其他元素所幹擾。

彈窗模式

很多場景下,爲了不干擾用戶,我們並不想使用蒙層。這時,我們可以使用無蒙層模式,即在功能點旁邊彈出一個簡單的窗口引導。

精準定位

初始定位

Guide 提供了 12 種對齊方式,將彈窗引導加載到所選擇的元素上。同時,還允許自定義橫縱向偏差值,對彈窗的位置進行調整。下圖分別展示了定位爲 top-left 和 right-bottom 的彈窗:

並且當用戶縮放或者滾動頁面時,彈窗的定位依然是準確的。

自動滾動

在很多情境中,我們都需要對距離較遠的幾個頁面元素進行功能說明,串聯成一個完整的引導路徑。當下一步要圈注的功能點不在用戶視野中時,Guide 會自動滾動頁面至合適的位置,並彈出引導窗口。

鍵盤操作

當 Guide 引導組件彈出時,我們希望用戶的注意力被完全吸引過來。爲了讓使用輔助閱讀器的用戶也能夠感知到 Guide 的出現,我們將頁面焦點移動到彈窗上,並且讓彈窗裏的每一個可讀元素都能夠聚焦。同時,用戶可以用鍵盤(tab 或 tab+shift)依次聚焦彈窗裏的內容,也可以按 escape 鍵退出引導。

下圖中,用戶用 tab 鍵在彈窗中移動焦點,被聚焦的元素用虛線框標識出來。當聚焦到 “下一步” 按鈕時,敲擊 shift 鍵,便可跳至下一步引導。

技術實現

總體流程

在展示組件的步驟前我們會先判斷是否過期,判斷是否過期的標準有兩個:一個是該引導組件在localStorage中存儲唯一 key 是否爲 true,爲 true 則爲該組件步驟執行完畢。第二個是組件接收一個props.expireDate,如果當前時間大於expireDate則代表組件已經過期則不會繼續展示。

當組件沒有過期時,會展示傳入的props.steps相應的內容,steps 結構如下:

interface Step {
    selector: string;
    title: string;
    content: React.Element | string;
    placement: 'top' | 'bottom' | 'left' | 'right'
        | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right',
    offset: Record<'top' | 'bottom' | 'left' | 'right', number>
}

const steps = Step[]

根據 step.selector 獲取高亮元素,再根據 step.placement 將彈窗展示到高亮元素相關的具體位置。點擊下一步會按序展示下個 step,當所有步驟展示完畢之後我們會將該引導組件在 localStorage 中存儲唯一 key 置爲 true,下次進來將不再展示。

下面來看看引導組件的具體細節實現吧。

蒙層模式

當前的引導組件支持有無蒙層兩種模式,有蒙層的展示效果如下圖所示。

蒙層很好實現,就是一個撐滿屏幕的 div,但是我們怎麼才能讓它做到高亮出中間的 selector 元素並且還支持圓角呢?🤔 ,真相只有一個,那就是—— border-width

我們拿到了 selector 元素的offsetTop, offsetRight, offsetBottom, offsetLeft,並相應地設置爲高亮框的border-width,再把border-color設置爲灰色,一個帶有高亮框的蒙層就實現啦!在給這個高亮框 div 加個pseudo-element ::after 來賦予它 border-radius,完美!

彈窗的定位

用戶使用 Guide 時,傳入了步驟信息,每一步都包括了所要進行引導說明的界面元素的 CSS 選擇器。我們將所要標註的元素叫做 “錨元素”。Guide 需要根據錨元素的位置信息,準確地定位彈窗。

每一個 HTML 元素都有一個只讀屬性 offsetParent,它指向最近的(指包含層級上的最近)包含該元素的定位元素或者最近的 table,td,th,body元素。每個元素都是根據它的 offsetParent 元素進行定位的。比如說,一個 absolute 定位的元素,是根據它最近的、非 static 定位的上級元素進行偏移的,這個上級元素,就是其的 offsetParent。

所以我們想到將彈窗元素放進錨元素的 offsetParent 中,再對其位置進行調整。同時,爲了不讓錨元素 offsetParent 中的其它元素產生位移,我們設定彈窗元素爲 absolute 絕對定位。

定位步驟

彈窗的定位計算流程大致如下:

步驟 1. 得到錨元素

通過傳給 Guide 的步驟信息中的 selector,即 CSS selector,我們可以由下述代碼拿到錨元素:

const anchor = document.querySelector(selector);

如何拿到 anchor 的 offsetParent 呢?這一步其實並沒有想象中那麼簡單。下面我們就來詳細地講一講這一步吧。

步驟 2. 獲取 offsetParent

一般來說,拿到錨元素的 offsetParent,也只需要簡單的一行代碼:

const parent = anchor.offsetParent;

但是這行代碼並不能涵蓋所有的場景,我們需要考慮一些特殊的情況。

場景一:錨元素爲 fixed 定位

並不是所有的 HTMLElement 都有 offsetParent 屬性。當錨元素爲 fixed 定位時,其 offsetParent 返回 null。這時,我們就需要使用其 包含塊(containing block) 代替 offsetParent 了。

包含塊是什麼呢?大多數情況下,包含塊就是這個元素最近的祖先塊元素的內容區,但也不是總是這樣。一個元素的包含塊是由其 position 屬性決定的。

因此,我們可以從錨元素開始,遞歸地向上尋找符合上述條件的父級元素,如果找不到,那麼就返回 document.documentElement

下面是 Guide 中用來尋找包含塊的代碼:

const getContainingBlock = node ={
  let currentNode = getDocument(node).documentElement;

  while (
    isHTMLElement(currentNode) &&
    !['html''body'].includes(getNodeName(currentNode))
  ) {
    const css = getComputedStyle(currentNode);

    if (
      css.transform !== 'none' ||
      css.perspective !== 'none' ||
      (css.willChange && css.willChange !== 'auto')
    ) {
      return currentNode;
    }
    currentNode = currentNode.parentNode;
  }

  return currentNode;
};
場景二:在 iframe 中使用 Guide

在 Guide 的代碼中,我們常常用到 window 對象。比如說,我們需要在 window 對象上調用 getComputedStyle()獲取元素的樣式,我們還需要 window 對象作爲元素 offsetParent 的兜底。但是我們並不能直接使用 window 對象,爲什麼呢?這時,我們需要考慮 iframe 的情況。

想象一下,如果我們在一個內嵌了 iframe 的應用中使用 Guide 組件,Guide 組件代碼在 iframe 外面,而被引導的功能點在 iframe 裏面,那麼在使用 Window 對象提供的方法是,我們一定是想在所圈注的功能點所在的 Window 對象上進行調用,而非當前代碼運行的 Window。

因此,我們通過下面的 getWindow 方法,確保拿到的是參數 node 所在的 Window。

// Get the window object using this function rather then simply use `window` because
// there are cases where the window object we are seeking to reference is not in
// the same window scope as the code we are running. (https://stackoverflow.com/a/37638629)
const getWindow = node ={
  // if node is not the window object
  if (node.toString() !== '[object Window]') {
    // get the top-level document object of the node, or null if node is a document.
    const { ownerDocument } = node;
    // get the window object associated with the document, or null if none is available.
    return ownerDocument ? ownerDocument.defaultView || window : window;
  }

  return node;
};

在 line 8,我們看到一個屬性 ownerDocument。如果 node 是一個 DOM Element,那麼它具有一個屬性 ownerDocument,此屬性返回的 document 對象是在實際的 HTML 文檔中的所有子節點所屬的主對象。如果在文檔節點自身上使用此屬性,則結果是 null。當 node 爲 Window 對象時,我們返回 window;當 node 爲 Document 對象時,我們返回了 ownerDocument.defaultView 。這樣,getWindow 函數便涵蓋了參數 node 的所有可能性。

步驟 3. 掛載彈窗

如下代碼所示,我們常常遇到的使用場景是,在組件 A 中渲染 Guide,讓其去標註的元素卻在組件 B、組件 C 中。

 // 組件A
 const A = props =(
    <>
        <Guide
            steps={[
                {
                    ......
                    selector: '#btn1'
                },
                {
                    ......
                    selector: '#btn2'
                },
                {
                    ......
                    selector: '#btn3'
                }
            ]}
        />
        <button id="btn1">Button 1</button>
    </>
)
// 組件B
const B = props =(<button id="btn2">Button 2</button>)
// 組件C
const C = props =(<button id="btn3">Button 3</button>)

上述代碼中,Guide 會自然而然地渲染在 A 組件 DOM 結構下,我們怎樣將其掛載到組件 B、C 的 offsetParent 中呢?這時候就要給大家介紹一下強大卻少爲人知的 React Portals 了。

React Portals

當我們需要把一個組件渲染到其父節點所在的 DOM 樹結構之外時, 我們首先應該考慮使用 React Portals。Portals 最適用於這種需要將子節點從視覺上渲染到其父節點之外的場景了,在 Antd 的 Modal、Popover、Tooltip 組件實現中,我們也可以看到 Portal 的應用。

我們使用 ReactDOM.createPortal(child, container)創建一個 Portal。child 是我們要掛載的組件,container 則是 child 要掛載到的容器組件。

雖然 Portal 是渲染在其父元素 DOM 結構之外的,但是它並不會創建一個完全獨立的 React DOM 樹。一個 Portal 與 React 樹中其它子節點相同,都可以拿到父組件的傳來的 props 和 context,也都可以進行事件冒泡。

另外,與 ReactDOM.render 所創建的 React DOM 樹不同,ReactDOM.createPortal 是應用在組件的 render 函數中的,因此不需要手動卸載。

在 Guide 中,每跳一步,上一步的彈窗便會卸載掉,新的彈窗會被加載到這一步要圈注的元素的 offsetParent 裏。僞代碼如下:

const Modal = props =(
    ReactDOM.createPortal(
        <div>
            ......
        </div>,
    offsetParent);
)

將彈窗渲染進 offsetParent 後,Guide 的下一步工作便是計算彈窗相對於 offsetParent 的偏移量。這一步非常複雜,並且要考慮一些特殊情況。下面就讓我們就仔細地講解這部分計算吧。

步驟 4. 偏移量計算

以一個 placement = left ,即需要在功能點左側展示的彈窗引導爲例。如果我們直接把彈窗通過 React Portal 掛載到錨元素的 offsetParent 中,並賦予其絕對定位,其位置會如下圖所示——左上角與 offsetParent 的左上角對齊。

下圖中,用藍色框表示的考拉圖片是 Guide 需要標註的元素,即錨元素;紅色框則標識出這個錨元素的 offsetParent 元素。

而我們預想的定位結果如下:

參考下圖,將彈窗從初始位置移動至預期位置,我們需要在 y 軸上向下移動彈窗 offsetTop + h1/2 - h2/2 px。其中,h1 爲錨元素的高度,h2 爲彈窗的高度。

但是,上述計算依然忽略了一種場景,那就是當錨元素定位爲 fixed 時。若錨元素定位爲 fixed,那麼無論錨元素所在的界面怎樣滑動,錨元素相對於屏幕視口(viewport)的位置是固定的。自然,用來對 fixed 錨元素進行引導的彈窗也需要具有這些特性,即同樣需要爲 fixed 定位。

Arrow 實現及定位

arrowmodal 的子元素且相對於 modal 絕對定位,如下圖所示有十二種展示位置,我們把十二種定位分爲兩類情況:

  1. 紫色的四種居中情況;

  2. 黃色的其餘八種斜角。

對於第一類情況

箭頭始終是相對彈窗邊緣居中的位置,出對於 top、bottom,箭頭的 right 值始終是(modal.width - arrow.diagonalWidth)/2 ,而 top 或 bottom 值始終爲-arrow.diagonalWidth/2

對於 left、right,箭頭的 top 值是(modal.height - arrow.diagonalWidth)/2 ,而 left 或 right 爲-arrow.diagonalWidth/2

注:diagonalWidth爲對角線寬度,getReversePosition\(placement\)爲獲取傳入參數的 reverse 位置,top 對應 bottom,left 對應 right。

僞代碼如下:

const placement = 'top' | 'bottom' | 'left' | 'right';
const diagonalWidth = 10;

const style = {
  right: ['bottom''top'].includes(placement)
    ? (modal.width - diagonalWidth) / 2
    : '',
  top: ['left''right'].includes(placement)
    ? (modal.height - diagonalWidth) / 2
    : '',
    [getReversePosition(placement)]: -diagonalWidth / 2,
};

對於第二類情況

對於 A-B 的位置,通過下圖可以發現,B 的位移總是固定值。比如對於 placement 值爲 top-left 的彈窗,箭頭 left 值總是固定的,而 bottom 值爲-arrow.diagonalWidth/2

以下爲僞代碼:

const [firstPlacement, lastPlacement] = placement.split('-');
const diagonalWidth = 10;
const margin = 24;

const style =  {
    [lastPlacement]: margin,
    [getReversePosition(placement)]: -diagonalWidth / 2,
}

Hotspot 實現及定位

引導組件支持 hotspot 功能,通過給一個 div 元素加上動畫改變其 box-shadow 大小實現呼吸燈的效果,效果如下圖所示,其中熱點的定位是相對箭頭的位置計算的,這裏便不贅述了。

結語

在 Guide 的開發初期,我們並沒有想到這樣一個小組件需要考慮到以上這些技術點。可見,再小的組件,讓其適用於所有場景,做到足夠通用都是件難事,需要不斷地嘗試與反思。

本文作者:Lilly Jiang & Wind

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/vrDQEGgOSnKBvHuwZV6vSA