命理难说

命理难说 查看完整档案

深圳编辑九江职业技术学院  |  计算机科学与技术 编辑深圳前海亿车科技有限公司  |  前端开发工程师 编辑 liaoyinglong.com/ 编辑
编辑

time will tell
联系我:
vigossliao@gmail.com

个人动态

命理难说 赞了文章 · 2020-09-27

【从源码分析】可能是全网最实用的React Native异常解决方案【建议收藏】

前言

在做React Native混合开发时,生产环境有时会遇到打开RN(即React Native简称)应用白屏、RN页面内操作闪退到native页面或者直接导致APP Crash的情况。通过分析APP日志,发现原因可以归类为以下两种:

  1. js 层编译运行时报错。一般是由于某些特殊的数据或情景导致js执行报错;
  2. js 转译 native UI 或与 native modules通信时出现异常.

对于第一点,可以很快地通过log追踪到出现问题的js代码并解决,但是对于第二点,往往是框架底层代码执行报错阻塞了UI渲染,报错日志信息无法定位出哪里出了问题,如:

06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: com.facebook.react.common.c: Error: JS Functions are not convertible to dynamic
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: 
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: This error is located at:
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in u
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in Tile
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in Tile
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in TouchableWithoutFeedback
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in Unknown
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in h
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTScrollView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in u
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in v
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in f
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in h
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in AndroidHorizontalScrollContentView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in AndroidHorizontalScrollView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in u
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in v
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in f
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in n
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in inject-with-store(n)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in MobXProvider
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in I
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in c, stack:
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: <unknown>@-1
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: value@28:2227
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: <unknown>@19:1668
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: Ci@89:62783
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: qi@89:66674
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: ea@89:69555
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: <unknown>@89:81296
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: unstable_runWithPriority@164:3238
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: ja@89:81253
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: Oa@89:81007
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: Wa@89:80310
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: Aa@89:79323
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: Ki@89:68624
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: Ki@-1
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: yt@89:21420
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: y@115:657
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: callTimers@115:2816
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: value@28:3311
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: <unknown>@28:822
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: value@28:2565
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: value@28:794
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: value@-1
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at com.facebook.react.modules.core.ExceptionsManagerModule.showOrThrowError(ExceptionsManagerModule.java:54)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at com.facebook.react.modules.core.ExceptionsManagerModule.reportFatalException(ExceptionsManagerModule.java:38)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at java.lang.reflect.Method.invoke(Native Method)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at com.facebook.react.bridge.JavaMethodWrapper.invoke(JavaMethodWrapper.java:372)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at com.facebook.react.bridge.JavaModuleWrapper.invoke(JavaModuleWrapper.java:158)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at com.facebook.react.bridge.queue.NativeRunnable.run(Native Method)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at android.os.Handler.handleCallback(Handler.java:907)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at android.os.Handler.dispatchMessage(Handler.java:105)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at com.facebook.react.bridge.queue.MessageQueueThreadHandler.dispatchMessage(MessageQueueThreadHandler.java:29)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at android.os.Looper.loop(Looper.java:216)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at com.facebook.react.bridge.queue.MessageQueueThreadImpl$4.run(MessageQueueThreadImpl.java:232)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at java.lang.Thread.run(Thread.java:784)

应用出现异常还不是最糟糕的,糟糕的是因为出现异常,带给了用户糟糕的体验,尽管实际出现几率非常低。
我们应该在出现异常时,通过降级UI(如web端常见的404页面、"网络开小差了,请稍后再试"弹窗)提示和安慰用户,并引导用户转向正常页面。
很遗憾,通常情况下我们现在并没有这个主动权,一切异常处理都是由 React Native 框架自己完成的。因此,我们要从React Native中接管异常处理权力来实现我们自己的逻辑(类似 反转控制反转 思想)

下面,将带领大家一步步分析并实现。

分析React Native 的红屏/黄屏提示

不管是何种原因导致RN应用异常,在开发模式环境(在发布版 release/production中都是自动禁用的),默认情况下都会以红屏(red box)或黄屏(yellow box)方式全屏提示:

请注意此文中,报错和警告,都视为异常

红屏:
red box.png
黄屏:
yellow box2.png

在官方描述中:

### 红屏错误

应用内的报错会以全屏红色显示在应用中(调试模式下),我们称为红屏(red box)报错。你可以使用`console.error()`来手动触发红屏错误。

### 黄屏警告

应用内的警告会以全屏黄色显示在应用中(调试模式下),我们称为黄屏(yellow box)报错。点击警告可以查看详情或是忽略掉。和红屏报警类似,你可以使用`console.warn()`来手动触发黄屏警告。

这2个全屏提示就是 React Native 对RN应用异常的处理。
那么思路来了,我们只需要找到 RN 弹出红屏、黄屏的地方,并将之替换为我们自己的业务逻辑即可
示意图如下:
接管RN异常处理逻辑.png

OK,接下来我们需要从源码中去找到这个切入口,不要害怕源码,跟着我的思路,let's go!

从源码上找出切入口

1.找出红屏切入点

在上述红屏图片中,我们通过 console.error('I am red box') 触发了红屏提示。在提示中打印出了错误栈追踪信息:

console.error: "I am red box"
error
    
<unknown>
    C:\workspace\test_timer_picker\node_modules\react-native\Libraries\Renderer\oss\ReactFabric-prod.js:6808:9
_callTimer
    C:\workspace\test_timer_picker\node_modules\react-native\Libraries\Renderer\oss\ReactNativeRenderer-dev.js:8778:10
callTimers
    C:\workspace\test_timer_picker\node_modules\react-native\Libraries\Renderer\oss\ReactNativeRenderer-dev.js:9080:8
__callFunction
    
<unknown>
    
__guard
    C:\workspace\test_timer_picker\node_modules\react-native\Libraries\ART\ReactNativeART.js:169:9
callFunctionReturnFlushedQueue
    
callFunctionReturnFlushedQueue
    [native code]

其中,指出了错误出现的文件位置:

\node_modules\react-native\Libraries\Renderer\oss\ReactFabric-prod.js
\node_modules\react-native\Libraries\Renderer\oss\ReactNativeRenderer-dev.js
\node_modules\react-native\Libraries\ART\ReactNativeART.js

依次在这几个文件中查询 console.error,可以在 ReactNativeRenderer-dev.js 文件中的showErrorDialog方法中找到这么一段注释:

  ExceptionsManager.handleException(errorToHandle, false);
  // Return false here to prevent ReactFiberErrorLogger default behavior of
  // logging error details to console.error. Calls to console.error are
  // automatically routed to the native redbox controller, which we've already
  // done above by calling ExceptionsManager.

意思是“调用 console.error 会自动导航到 native 红屏 controller” ,再查看showErrorDialog方法的注释:

/**
 * Intercept lifecycle errors and ensure they are shown with the correct stack
 * trace within the native redbox component.
 */
function showErrorDialog(capturedError) {/****/}

意思是“截获生命周期错误,并确保在native redbox 组件中显示正确的堆栈跟踪”
Perfect,我们根据错误栈信息一下找到了红屏的原因!
再仔细看这一句注释:

  //Calls to console.error are
  // automatically routed to the native redbox controller, which we've already
  // done above by calling ExceptionsManager.

“调用 console.error 会自动导航到 native 红屏 controller的原因,是我们已经在上面调用了 ExceptionsManager”

那么此时,我们可以想到,产生红屏 === 因为 ExceptionsManager 做了什么 我们要做的是去将ExceptionsManager实现的逻辑替换成我们自己的逻辑!

小提示: 源码中仔细寻找showErrorDialog()被调用的位置,你会找到logCapturedError()以及更上层的logError(),分析logError(),你会发现,原来 React 中的错误边界能捕获到组件渲染时错误也与之有关

ok,继续看 ExceptionsManager.js,它的路径为:node_modules\react-native\Libraries\Core\ExceptionsManager.js,内容如下:

/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @format
 * @flow
 */

'use strict';

import type {ExtendedError} from 'parseErrorStack';

/**
 * Handles the developer-visible aspect of errors and exceptions
 */
let exceptionID = 0;
function reportException(e: ExtendedError, isFatal: boolean) {
  const {ExceptionsManager} = require('NativeModules');
  if (ExceptionsManager) {
    const parseErrorStack = require('parseErrorStack');
    const stack = parseErrorStack(e);
    const currentExceptionID = ++exceptionID;
    const message =
      e.jsEngine == null ? e.message : `${e.message}, js engine: ${e.jsEngine}`;
    if (isFatal) {
      ExceptionsManager.reportFatalException(
        message,
        stack,
        currentExceptionID,
      );
    } else {
      ExceptionsManager.reportSoftException(message, stack, currentExceptionID);
    }
    if (__DEV__) {
      const symbolicateStackTrace = require('symbolicateStackTrace');
      symbolicateStackTrace(stack)
        .then(prettyStack => {
          if (prettyStack) {
            ExceptionsManager.updateExceptionMessage(
              e.message,
              prettyStack,
              currentExceptionID,
            );
          } else {
            throw new Error('The stack is null');
          }
        })
        .catch(error =>
          console.warn('Unable to symbolicate stack trace: ' + error.message),
        );
    }
  }
}

declare var console: typeof console & {
  _errorOriginal: Function,
  reportErrorsAsExceptions: boolean,
};

/**
 * Logs exceptions to the (native) console and displays them
 */
function handleException(e: Error, isFatal: boolean) {
  // Workaround for reporting errors caused by `throw 'some string'`
  // Unfortunately there is no way to figure out the stacktrace in this
  // case, so if you ended up here trying to trace an error, look for
  // `throw '<error message>'` somewhere in your codebase.
  if (!e.message) {
    e = new Error(e);
  }
  if (console._errorOriginal) {
    console._errorOriginal(e.message);
  } else {
    console.error(e.message);
  }
  reportException(e, isFatal);
}

function reactConsoleErrorHandler() {
  console._errorOriginal.apply(console, arguments);
  if (!console.reportErrorsAsExceptions) {
    return;
  }

  if (arguments[0] && arguments[0].stack) {
    reportException(arguments[0], /* isFatal */ false);
  } else {
    const stringifySafe = require('stringifySafe');
    const str = Array.prototype.map.call(arguments, stringifySafe).join(', ');
    if (str.slice(0, 10) === '"Warning: ') {
      // React warnings use console.error so that a stack trace is shown, but
      // we don't (currently) want these to show a redbox
      // (Note: Logic duplicated in polyfills/console.js.)
      return;
    }
    const error: ExtendedError = new Error('console.error: ' + str);
    error.framesToPop = 1;
    reportException(error, /* isFatal */ false);
  }
}

/**
 * Shows a redbox with stacktrace for all console.error messages.  Disable by
 * setting `console.reportErrorsAsExceptions = false;` in your app.
 */
function installConsoleErrorReporter() {
  // Enable reportErrorsAsExceptions
  if (console._errorOriginal) {
    return; // already installed
  }
  // Flow doesn't like it when you set arbitrary values on a global object
  console._errorOriginal = console.error.bind(console);
  console.error = reactConsoleErrorHandler;
  if (console.reportErrorsAsExceptions === undefined) {
    // Individual apps can disable this
    // Flow doesn't like it when you set arbitrary values on a global object
    console.reportErrorsAsExceptions = true;
  }
}

module.exports = {handleException, installConsoleErrorReporter};

我们通过语义良好的方法名以及清晰的注释可以了解到:
其暴露了2个方法:

  1. handleException —— 通过console.error() & reportException()处理凡是以throw '<error message>'方式抛出的异常;
  2. installConsoleErrorReporter —— 重载 console.error,只要是使用 console.error打印信息都会以“红屏”的方式显示错误堆栈信息。支持设置console.reportErrorsAsExceptions = false; 将此行为关闭。

分析到这一步,可以明显地感觉到,一切指向 console.error 方法!!

我们继续在 react native 源码中进行查询,找到installConsoleErrorReporter()方法在
node_modules\react-native\Libraries\Core\setUpErrorHandling.js 中被调用:

/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow strict-local
 * @format
 */
'use strict';

/**
 * Sets up the console and exception handling (redbox) for React Native.
 * You can use this module directly, or just require InitializeCore.
 */
const ExceptionsManager = require('ExceptionsManager');
ExceptionsManager.installConsoleErrorReporter();

// Set up error handler
if (!global.__fbDisableExceptionsManager) {
  const handleError = (e, isFatal) => {
    try {
      ExceptionsManager.handleException(e, isFatal);
    } catch (ee) {
      console.log('Failed to print error: ', ee.message);
      throw e;
    }
  };

  const ErrorUtils = require('ErrorUtils');
  ErrorUtils.setGlobalHandler(handleError);
}

其注释十分清晰地指出:“为 React Native 设置 console 以及 异常处理(红屏)”

其核心设置代码是:

  const ErrorUtils = require('ErrorUtils');
  ErrorUtils.setGlobalHandler(handleError); // 这就是我们要找的切入点

这就是我们要找的最终切入点,所有异常全部由ErrorUtils.setGlobalHandler的回调函数处理,只要将其设置为我们自己定义的回调函数就能从RN手中接过异常处理权了!!!
如:

    global.ErrorUtils.setGlobalHandler(e=> {
      /*处理异常*/
      console.log('%c 处理异常 .....', 'font-size:12px;color:#869')
      console.log(e.message)
      // do something to handle exception
      //...
    })

Nice~,接下来我们继续寻找黄屏(yellow box)的原因。


2.找出黄屏切入点

与红屏报错原因不同,熟悉js开发的同学应该知道,唯一能输出警告信息的就是调用console.warn()。在上述的黄屏提示中,并没有打印出栈追踪信息,但是我们可以开启debug模式(开发者菜单 -> Debug JS Remotely),可以在控制台看到更加详细的栈追踪信息:
yellow box stack.png

很明显,黄屏提示是由YellowBox.js输出的。
继续查看 RN 源码,找到其位置:node_modules\react-native\Libraries\YellowBox\YellowBox.js,内容如下:

/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 * @format
 */

'use strict';

const React = require('React');

import type {Category} from 'YellowBoxCategory';
import type {Registry, Subscription} from 'YellowBoxRegistry';

type Props = $ReadOnly<{||}>;
type State = {|
  registry: ?Registry,
|};

let YellowBox;

/**
 * YellowBox displays warnings at the bottom of the screen.
 *
 * Warnings help guard against subtle yet significant issues that can impact the
 * quality of the app. This "in your face" style of warning allows developers to
 * notice and correct these issues as quickly as possible.
 *
 * YellowBox is only enabled in `__DEV__`. Set the following flag to disable it:
 *
 *   console.disableYellowBox = true;
 *
 * Ignore specific warnings by calling:
 *
 *   YellowBox.ignoreWarnings(['Warning: ...']);
 *
 * Strings supplied to `YellowBox.ignoreWarnings` only need to be a substring of
 * the ignored warning messages.
 */
if (__DEV__) {
  const Platform = require('Platform');
  const RCTLog = require('RCTLog');
  const YellowBoxList = require('YellowBoxList');
  const YellowBoxRegistry = require('YellowBoxRegistry');

  const {error, warn} = console;

  // eslint-disable-next-line no-shadow
  YellowBox = class YellowBox extends React.Component<Props, State> {
    static ignoreWarnings(patterns: $ReadOnlyArray<string>): void {
      YellowBoxRegistry.addIgnorePatterns(patterns);
    }

    static install(): void {
      (console: any).error = function(...args) {
        error.call(console, ...args);
        // Show YellowBox for the `warning` module.
        if (typeof args[0] === 'string' && args[0].startsWith('Warning: ')) {
          registerWarning(...args);
        }
      };

      (console: any).warn = function(...args) {
        warn.call(console, ...args);
        registerWarning(...args);
      };

      if ((console: any).disableYellowBox === true) {
        YellowBoxRegistry.setDisabled(true);
      }
      (Object.defineProperty: any)(console, 'disableYellowBox', {
        configurable: true,
        get: () => YellowBoxRegistry.isDisabled(),
        set: value => YellowBoxRegistry.setDisabled(value),
      });

      if (Platform.isTesting) {
        (console: any).disableYellowBox = true;
      }

      RCTLog.setWarningHandler((...args) => {
        registerWarning(...args);
      });
    }

    static uninstall(): void {
      (console: any).error = error;
      (console: any).warn = error;
      delete (console: any).disableYellowBox;
    }

    _subscription: ?Subscription;

    state = {
      registry: null,
    };

    render(): React.Node {
      // TODO: Ignore warnings that fire when rendering `YellowBox` itself.
      return this.state.registry == null ? null : (
        <YellowBoxList
          onDismiss={this._handleDismiss}
          onDismissAll={this._handleDismissAll}
          registry={this.state.registry}
        />
      );
    }

    componentDidMount(): void {
      this._subscription = YellowBoxRegistry.observe(registry => {
        this.setState({registry});
      });
    }

    componentWillUnmount(): void {
      if (this._subscription != null) {
        this._subscription.unsubscribe();
      }
    }

    _handleDismiss = (category: Category): void => {
      YellowBoxRegistry.delete(category);
    };

    _handleDismissAll(): void {
      YellowBoxRegistry.clear();
    }
  };

  const registerWarning = (...args): void => {
    YellowBoxRegistry.add({args, framesToPop: 2});
  };
} else {
  YellowBox = class extends React.Component<Props> {
    static ignoreWarnings(patterns: $ReadOnlyArray<string>): void {
      // Do nothing.
    }

    static install(): void {
      // Do nothing.
    }

    static uninstall(): void {
      // Do nothing.
    }

    render(): React.Node {
      return null;
    }
  };
}

module.exports = YellowBox;

它是一个 class 组件,大概逻辑是:“劫持宿主环境的console.warn,并将警告信息用原生 YellowBoxList渲染出来;同时也劫持console.error,将React环境中以error级别输出的警告信息还原成warning级别的日志(避免影响理解,这一点无需理会)”

这就是黄屏的切入点了,仅仅是将警告日志以另一种方式输出而已,好像与我们要做的事情无关,但是真的无关吗?

时刻记住,应用的每一个 error 和 warn 级别的日志都不应该忽视,尤其是warn级别的日志!

让我们看下以下代码:

  // 模拟异步操作 可能是请求、可能是与native modules 方法通信
  mockAsyncHandle = ()=>{
    return new Promise((resolve,reject)=>{
      // 执行异常
      throw new Error([1,2,3].toString())
    })
  }

  async componentDidMount(){
    const resp = await this.mockAsyncHandle() // 执行异常
    // 后续代码不会再执行
    console.log(resp)
    // 使用 resp 去做业务处理,可能是更新state 也可能是某些操作的前提条件
    // ...
  }

这段代码会触发一个 yellow box 黄屏提示, warning 级别日志如下:
unhandled Promise.png

有过Promise丰富使用经验的同学可能已经发现了,在这里,throw new Error([1,2,3].toString()) 抛出的异常被吞掉了,代码中依赖resp的逻辑全部会失败,非常严重的异常!你可能想到链式调用Promise.prototye.catch()去处理拒绝状态的Promise,但是假如catch处理函数中继续抛出异常呢?这种现象在《你所不知道的JavaScript》书中被称为“绝望的陷阱”,与 try...catch 一样,始终会吞掉最后的异常。

在 web 端,浏览器会自动追踪内存使用情况,通过垃圾回收机制处理这个 rejected Promise,并且提供unhandledrejection事件进行监听。

那么,在RN中,此类Promise异常怎么处理呢?

查看源码node_modules\react-native\Libraries\Promise.js 可知,RN扩展了ES6 Promise :

/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @format
 * @flow
 */

'use strict';

const Promise = require('promise/setimmediate/es6-extensions');
require('promise/setimmediate/done');

Promise.prototype.finally = function(onSettled) {
  return this.then(onSettled, onSettled);
};

if (__DEV__) {
  /* $FlowFixMe(>=0.54.0 site=react_native_oss) This comment suppresses an
   * error found when Flow v0.54 was deployed. To see the error delete this
   * comment and run Flow. */
  require('promise/setimmediate/rejection-tracking').enable({
    allRejections: true,
    onUnhandled: (id, error = {}) => {
      let message: string;
      let stack: ?string;

      const stringValue = Object.prototype.toString.call(error);
      if (stringValue === '[object Error]') {
        message = Error.prototype.toString.call(error);
        stack = error.stack;
      } else {
        /* $FlowFixMe(>=0.54.0 site=react_native_oss) This comment suppresses
         * an error found when Flow v0.54 was deployed. To see the error delete
         * this comment and run Flow. */
        message = require('pretty-format')(error);
      }

      const warning =
        `Possible Unhandled Promise Rejection (id: ${id}):\n` +
        `${message}\n` +
        (stack == null ? '' : stack);
      console.warn(warning);
    },
    onHandled: id => {
      const warning =
        `Promise Rejection Handled (id: ${id})\n` +
        'This means you can ignore any previous messages of the form ' +
        `"Possible Unhandled Promise Rejection (id: ${id}):"`;
      console.warn(warning);
    },
  });
}

module.exports = Promise;

RN 默认在开发环境下,通过promise/setimmediate/rejection-tracking去追踪 rejected 状态的Promise,并提供了onUnhandled回调函数处理未进行处理的 rejected Promise,其执行时机可以在rejection-tracking.js中源码中找到:

//...
timeout: setTimeout(
    onUnhandled.bind(null, promise._51),
    // For reference errors and type errors, this almost always
    // means the programmer made a mistake, so log them after just
    // 100ms
    // otherwise, wait 2 seconds to see if they get handled
    matchWhitelist(err, DEFAULT_WHITELIST)
      ? 100
      : 2000
  ),
//...

与错误处理类似,我们只需将 onUnhandled回调函数替换成我们自定义的Promise 异常处理逻辑就能从RN手中接管Promise异常处理了!!!

OK,通过分析源码,我们已经理清思路并知道应该如何做了,接下来动手实现吧。

完美的解决方案

方案:错误边界 + ErrorUtils + promise rejection tracking

在前言中有提到:

我们应该在出现异常时,通过降级UI(如web端常见的404页面、"网络开小差了,请稍后再试"弹窗)提示安慰用户,并引导用户转向正常页面。

例如下面的提示(demo):
subUI.png

有 React 开发经验的同学应该知道,React 16+ 提供了一个方案:错误边界(Error Boundaries),完美地契合了我们逻辑上的要求。
官方demo如下:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 更新 state 使下一次渲染能够显示降级后的 UI
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 你同样可以将错误日志上报给服务器
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 你可以自定义降级后的 UI 并渲染
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children; 
  }
}

但是错误边界有以下缺陷:

错误边界无法捕获以下场景中产生的错误:

  • 事件处理(了解更多
  • 异步代码(例如 setTimeoutrequestAnimationFrame 回调函数)
  • 服务端渲染(RN中可以忽略此条)
  • 它自身抛出来的错误(并非它的子组件)

很幸运,通过我们上述源码的分析,我们可以在错误边界中通过global.ErrorUtils.setGlobalHandler(callback)注册RN错误处理回调函数以及设置rejection-tracking.jsonUnhandled函数来处理未处理的 rejected Promise.

来看看修改后的最终代码,升级版错误边界:

import React from 'react'
import PropTypes from 'prop-types'

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props)
    this.state = { hasError: false }

    global.ErrorUtils.setGlobalHandler(e=> {
      /*你的异常处理逻辑*/
      console.log('%c 处理异常 .....', 'font-size:12px;color:#869')
      console.log(e.message)
      this.setState({
        hasError: true
      })
    })
    require('promise/setimmediate/rejection-tracking').enable({
      allRejections: true,
      onUnhandled: (id, error = {}) => {
        let message
        let stack
  
        const stringValue = Object.prototype.toString.call(error);
        if (stringValue === '[object Error]') {
          message = Error.prototype.toString.call(error);
          stack = error.stack;
        } else {
          /* $FlowFixMe(>=0.54.0 site=react_native_oss) This comment suppresses
           * an error found when Flow v0.54 was deployed. To see the error delete
           * this comment and run Flow. */
          message = require('pretty-format')(error);
        }
  
        const warning =
          `Possible Unhandled Promise Rejection (id: ${id}):\n` +
          `${message}\n` +
          (stack == null ? '' : stack);
        console.warn(warning);
        // 更新 state 使下一次渲染能够显示降级后的 UI
        this.setState({
          hasError: true
        })
      },
      onHandled: id => {
        const warning =
          `Promise Rejection Handled (id: ${id})\n` +
          'This means you can ignore any previous messages of the form ' +
          `"Possible Unhandled Promise Rejection (id: ${id}):"`;
        console.warn(warning);
      },
    });
  }

  static propTypes={
    //自定义降级后的 UI
    errorPage:PropTypes.element,
    //可以根据自己的实际业务需求再增加其他属性,比如配置开发模式下是否要关闭红屏/黄屏显示
  }

  static getDerivedStateFromError(error) {
    // 更新 state 使下一次渲染能够显示降级后的 UI
    return { hasError: true }
  }

  componentDidCatch(error, errorInfo) {
    // 你同样可以将错误日志上报给服务器
    console.log(error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      // 你可以自定义降级后的 UI 并渲染
      return this.props.errorPage? this.props.errorPage:<h1>Something went wrong.</h1>
    }

    return this.props.children
  }
}
export default ErrorBoundary

使用方式与错误边界使用方式相同,在组件树最顶层,即包裹根组件使用:

//ErrorPage 是你自定义的降级显示UI
<ErrorBoundary errorPage={<ErrorPage/>}>
  <App/>
</ErrorBoundary>

ErrorPage 是你自定义的降级显示UI

完美,自此,RN应用中所用的异常全部由我们自己掌控处理了!快去项目中试试吧

附注

本文中的 React Native 源码分析,皆来自于 0.59.9 版本,但我也查阅分析了最新的 0.62.2 版本源码,除了部分文件内容有新增以外,本文涉及的 API 均未发生破坏性更改,请放心食用。

另外,有消息称 React Native 架构重构将于2020年第4季度,也就是今年完成,架构演变如下:
rn 架构重构.png

图片来源于 React Native maintainer——Lorenzo S.

希望到时 React Native 能带给我们更好的开发与使用体验!

FAQ

最后,回答几个大家可能有的疑问:

  1. 为什么不用 try...catch?
    答: 无法确定哪个代码块会出现异常,大量使用try...catch 会存在性能问题,并且它只能捕获同步代码中的异常,对于异步代码中可能出现的异常束手无策;另外它也存在 “绝望的陷阱” 这一问题。
  2. ErrorUtils 能捕获异步的异常吗?
    答:可以。只要是RN应用内抛出的异常都会被 ErrorUtils 捕获。
  3. ErrorUtils 为什么不能捕获Promise中的异常?
    答:因为对于JSC来说,此时并没有发生错误,当然无法被捕获。我们所说的 Promise 异常,其实是Promise 设计缺陷导致一个 rejected Promise 一直未被处理,表现为:异常被吞掉了。因此我们需要定义onUnhandled进行处理。
  4. 可以使用function component 来编写错误边界吗?
    答:不可以。错误边界只能是 Class 组件。如果你想把 ErrorUtils 与 Promise 异常处理从错误边界中剥离出来放到其他函数式组件中也是可以的,但是从组件化设计的角度来看的话,不推荐这样做。

声明

原创分享不易,觉得对你有所帮助的话,欢迎点赞收藏。
转载需经本人同意,并附上思否原文链接。
谢谢!

查看原文

赞 14 收藏 9 评论 12

命理难说 收藏了文章 · 2020-06-09

修复一个因为 scrollbar 占据空间导致的 bug

背景

这一个因为滚动条占据空间引起的bug, 查了一下资料, 最后也解决了,顺便研究一下这个属性, 做一下总结,分享给大家看看。

正文

昨天, 测试提了个问题, 现象是一个输入框的聚焦提示偏了, 让我修一下, 如下图:

image.png

起初认为是红框提示位置不对, 就去找代码看:

<Input
  // ...
  onFocus={() => setFocusedInputName('guidePrice')}
  onBlur={() => setFocusedInputName('')}
/>

<Table
  data-focused-column={focusedInputName}
  // ...
/>

代码上没有什么问题, 不是手动设置的,而且, 在我和另一个同事, 还有PM的PC上都是OK的:

image.png

初步判断是,红框位置结算有差异, 差异大小大概是17px, 但是这个差异是怎么产生的呢?

就去测试小哥的PC上看, 注意到一个细节, 在我PC上, 滚动条是悬浮的: image.png

在他PC上, 滚动条是占空间的:

image.png

在他电脑上, 手动把原本的 overscroll-y: scroll 改成 overscroll-y: overlay 问题就结局了。

由此判定是: 滚动条占据空间 引起的bug。

overscroll-y: overlay

CSS属性 overflow, 定义当一个元素的内容太大而无法适应块级格式化上下文的时候该做什么。它是 overflow-x 和overflow-y的 简写属性 。
/* 默认值。内容不会被修剪,会呈现在元素框之外 */
overflow: visible;

/* 内容会被修剪,并且其余内容不可见 */
overflow: hidden;

/* 内容会被修剪,浏览器会显示滚动条以便查看其余内容 */
overflow: scroll;

/* 由浏览器定夺,如果内容被修剪,就会显示滚动条 */
overflow: auto;

/* 规定从父元素继承overflow属性的值 */
overflow: inherit;

官方描述:

overlay  行为与 auto 相同,但滚动条绘制在内容之上而不是占用空间。 仅在基于 WebKit(例如,Safari)和基于Blink的(例如,ChromeOpera)浏览器中受支持。

表现:

html {
  overflow-y: overlay;
}

兼容性

没有在caniuse上找到这个属性的兼容性, 也有人提这个问题:

image.png

问题场景以及解决办法

1. 外部容器的滚动条

这里的外部容器指的是html, 直接加在最外层:

html {
  overflow-y: scroll;
}

手动加上这个特性, 不论什么时候都有滚动宽度占据空间。

缺点: 没有滚动的时候也会有个滚动条, 不太美观。

优点: 方便, 没有兼容性的问题。

2. 外部容器绝对定位法

用绝对定位,保证了body的宽度一直保持完整空间:

html {
  overflow-y: scroll; // 兼容ie8,不支持:root, vw
}

:root {
  overflow-y: auto;
  overflow-x: hidden;
}

:root body {
  position: absolute;
}

body {
  width: 100vw;
  overflow: hidden;
}

3. 内部容器做兼容


.wrapper {
    overflow-y: scroll; // fallback
    overflow-y: overlay;
}

总结

个人推荐还是用 overlay, 然后使用scroll 做为兜底。

内容就这么多, 希望对大家有所启发。

文章如有错误, 请在留言区指正, 谢谢。

关注我

如果你觉得这篇内容对你挺有启发,那就关注我吧~

图片

更多精彩:

聊聊 ESM、Bundleless 、Vite 、Snowpack

记一次 「 无限列表 」滚动优化

「 面试三板斧 」之 代码分割(上)

「 面试三板斧 」之缓存 (上)

「 面试三板斧 」之缓存 (下)

「 面试三板斧 」之 HTTP (上)

「 面试三板斧 」之 HTTP (下)

「 面试三板斧 」之  this

参考资料

  1. https://developer.mozilla.org...
  2. https://github.com/Fyrd/caniu...
查看原文

命理难说 赞了文章 · 2020-06-09

修复一个因为 scrollbar 占据空间导致的 bug

背景

这一个因为滚动条占据空间引起的bug, 查了一下资料, 最后也解决了,顺便研究一下这个属性, 做一下总结,分享给大家看看。

正文

昨天, 测试提了个问题, 现象是一个输入框的聚焦提示偏了, 让我修一下, 如下图:

image.png

起初认为是红框提示位置不对, 就去找代码看:

<Input
  // ...
  onFocus={() => setFocusedInputName('guidePrice')}
  onBlur={() => setFocusedInputName('')}
/>

<Table
  data-focused-column={focusedInputName}
  // ...
/>

代码上没有什么问题, 不是手动设置的,而且, 在我和另一个同事, 还有PM的PC上都是OK的:

image.png

初步判断是,红框位置结算有差异, 差异大小大概是17px, 但是这个差异是怎么产生的呢?

就去测试小哥的PC上看, 注意到一个细节, 在我PC上, 滚动条是悬浮的: image.png

在他PC上, 滚动条是占空间的:

image.png

在他电脑上, 手动把原本的 overscroll-y: scroll 改成 overscroll-y: overlay 问题就结局了。

由此判定是: 滚动条占据空间 引起的bug。

overscroll-y: overlay

CSS属性 overflow, 定义当一个元素的内容太大而无法适应块级格式化上下文的时候该做什么。它是 overflow-x 和overflow-y的 简写属性 。
/* 默认值。内容不会被修剪,会呈现在元素框之外 */
overflow: visible;

/* 内容会被修剪,并且其余内容不可见 */
overflow: hidden;

/* 内容会被修剪,浏览器会显示滚动条以便查看其余内容 */
overflow: scroll;

/* 由浏览器定夺,如果内容被修剪,就会显示滚动条 */
overflow: auto;

/* 规定从父元素继承overflow属性的值 */
overflow: inherit;

官方描述:

overlay  行为与 auto 相同,但滚动条绘制在内容之上而不是占用空间。 仅在基于 WebKit(例如,Safari)和基于Blink的(例如,ChromeOpera)浏览器中受支持。

表现:

html {
  overflow-y: overlay;
}

兼容性

没有在caniuse上找到这个属性的兼容性, 也有人提这个问题:

image.png

问题场景以及解决办法

1. 外部容器的滚动条

这里的外部容器指的是html, 直接加在最外层:

html {
  overflow-y: scroll;
}

手动加上这个特性, 不论什么时候都有滚动宽度占据空间。

缺点: 没有滚动的时候也会有个滚动条, 不太美观。

优点: 方便, 没有兼容性的问题。

2. 外部容器绝对定位法

用绝对定位,保证了body的宽度一直保持完整空间:

html {
  overflow-y: scroll; // 兼容ie8,不支持:root, vw
}

:root {
  overflow-y: auto;
  overflow-x: hidden;
}

:root body {
  position: absolute;
}

body {
  width: 100vw;
  overflow: hidden;
}

3. 内部容器做兼容


.wrapper {
    overflow-y: scroll; // fallback
    overflow-y: overlay;
}

总结

个人推荐还是用 overlay, 然后使用scroll 做为兜底。

内容就这么多, 希望对大家有所启发。

文章如有错误, 请在留言区指正, 谢谢。

关注我

如果你觉得这篇内容对你挺有启发,那就关注我吧~

图片

更多精彩:

聊聊 ESM、Bundleless 、Vite 、Snowpack

记一次 「 无限列表 」滚动优化

「 面试三板斧 」之 代码分割(上)

「 面试三板斧 」之缓存 (上)

「 面试三板斧 」之缓存 (下)

「 面试三板斧 」之 HTTP (上)

「 面试三板斧 」之 HTTP (下)

「 面试三板斧 」之  this

参考资料

  1. https://developer.mozilla.org...
  2. https://github.com/Fyrd/caniu...
查看原文

赞 10 收藏 5 评论 0

命理难说 赞了文章 · 2020-06-08

如何解决滚动条scrollbar出现造成的页面宽度被挤压的问题?

引言

页面滚动条造成宽度减小的场景很常见了,由于div块级元素的流动性,其宽度默认为100%的body宽度,但是当容器的高度超过视口宽度时候,页面就会出现滚动条,这个滚动条的宽度就会挤压body的可用宽度,也就是会挤压我们的容器的宽度,造成页面晃动的现象,很不友好,下面就来探讨下如何解决这个滚动条的问题。

滚动条的宽度是多少?

既然要解决滚动条造成的问题那么首先需要了解滚动条,即scrollbar的信息主要就是他的宽度,我们把页面的overflow置为scroll,那么滚动条就会默认占据了空间,下面代码就可以很容易得到其宽度了:

CSS: 先把body的间距置为0

* {
  margin: 0;
  padding: 0;
}
html {
  overflow-y: scroll;
}

JS: 用视口的innerWidth减去body就是滚动条的宽度

console.log('chrome下滚动条的宽度', window.innerWidth - document.body.clientWidth)

可以得出chrome浏览器下,宽度为17px,我在jsfiddle中写的话打印出来是16px,我没有在所有浏览器都去验证,但是各浏览器的值可能略有不同,但都是一个固定的值。以chrome来说,就是在触发页面滚动条时候,会挤压掉17px的空间,那我们就可以从不同角度考虑去解决了。

如何解决?

一、最原始的scroll方法

首先来讲下最原始的方法,其思想是既然在触发滚动条时候会挤压空间,那么直接在没有滚动条的时候也触发不就可以了么,也就是我们上面算宽度时候的设置:

html {
  overflow-y: scroll;
}

这样不论什么时候都有滚动宽度占据空间,不存在挤压的问题了...但是这样做有点蠢,毕竟在不需要滚动条的时候也有那么个丑丑的条子放在右边。但是他的优点在于方便而且没有兼容性的问题,其实很多大网站有时候也就这样用了。。。

二、新属性overlay方法

chrome下overflow有个新的属性值overlay,这个属性简直就是为了这个问题而生,他和auto有点像,但是区别就是在触发滚动条时候并不挤压空间,说得直白点就像是移动端的悬浮滚动条,唯一的区别就是不会像手机上那样自动出现自动消失了,滚动条会遮盖住容器17px的空间。眼见为实用下面代码看一下就知道。
高度还未触发滚动条时候:

* {
  margin: 0;
  padding: 0;
}
html {
  overflow-y: overlay;
}
.container {
  height: 200px;
  padding: 17px;
  background-color: #00b83f;
  text-align: right;
}


<div class="container">
  <h1>我是容器内容</h1>
</div>

效果图如下:
图片描述

然后修改容器高度,触发滚动条:

.container {
  height: 2000px;
  padding: 17px;
  background-color: #00b83f;
  text-align: right;
}

效果图如下:
图片描述

可以看到虽然出现了滚动条但是并未挤压容器的宽度,而是遮住了17px的空间,其实本质上就相当于实现了移动端的滚动条表现。
但是非常遗憾,这个属性值目前只有chrome支持,要是ff/ie都支持,后面也就不用写了,但是据说以后都会加上去支持的,可以说是非常好用了,后面的方法也只是用其他方法实现这个效果而已。

三、利用vw和calc实现

因为100vw是window的宽度,其实就是window.innerWidth, 而容器的宽度100%就是除了滚动条的可用宽度,因此在没有滚动条时候calc(100% - 100vw)就是0,触发滚动条时候其值为负的滚动条宽度,我们将其赋值给容器的margin-right,即可巧妙补偿这个宽度的挤压,在滚动条存在的情况下容器宽度仍然占据整个视口的宽度。

* {
  margin: 0;
  padding: 0;
}
html {
  overflow-y: auto;
  overflow-x: hidden;
}
.container {
  height: 2000px;
  margin-right: calc(100% - 100vw);
  padding: 17px;
  background-color: #00b83f;
  text-align: right;
}

效果如同方法二,很完美,并且兼容性还不错,起码高版本的ie和ff都没问题了。

四、张鑫旭大佬那里抄来的absolute方法

链接在此,这个利用了绝对定位,保证了body的宽度一直保持完整空间。

html {
  overflow-y: scroll; //这是为了兼容ie8,不支持:root, vw
}

:root {
  overflow-y: auto;
  overflow-x: hidden;
}

:root body {
  position: absolute;
}

body {
  width: 100vw;
  overflow: hidden;
}

在一个普通的容器滚动条挤压怎么办?

上面是针对浏览器视口的滚动条方案,但是假如在一个普通div容器中也有如此的需求改怎么办呢?因此此时并没有像100vw这样的值直接获取容器的宽度,只能使用js的方法来检测计算然后再用margin-right做补偿,原理都是一样的,但是我强烈不推荐也不喜欢用js来计算布局...因此在这种情况下勉强委屈下用上面的第一种方法了,如果是chrome下用第二种方法。

.wrap {
    overflow-y: scroll;
    overflow-y: overlay;
}

当然你坚决不能忍的话也可以用js去算吧...本质也是一样的,这里给个链接作为参考

总结

方法主要就是上面的几种,大家根据需求自由选用即可,最重要的是在使用某些新属性的时候多加思考,很多问题的本质并没什么区别,只是用新的工具去做而已。

参考

都在文中了

查看原文

赞 18 收藏 8 评论 0

命理难说 收藏了文章 · 2020-05-25

Web界面深色模式和主题化开发

DevUI是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部数个中后台系统,服务于设计师和前端工程师。
官方网站:devui.design
Ng组件库:ng-devui(欢迎Star)
官方交流群:添加 DevUI小助手(微信号:devui-official)进群

引言

深色模式(Dark Mode)在iOS13 引入该特性后各大应用和网站都开始支持了深色模式。在这之前,深色模式更常见于程序IDE开发界面和视频网站界面。前者通过降低屏幕亮度,使得使用人员长时间盯着屏幕眼睛没有那么疲惫;后者通过深色模式来降噪,从而突出主体内容部分。快速开发一个深色模式难吗? 在支持css自定义属性(又称css变量,css variables)的现代浏览器里,可以说是相当的容易。甚至可以在运行时实时新增主题,摆脱传统css主题文件加载模式下的主题需要预编译内置不能随时修改的弊端。下面我们来看一下如何使用css自定义属性来完成深色模式和主题化的开发。

主题切换器开发

首先我们需要打通一套支持css自定义属性的开发模式。

CSS自定义属性使用

这里简单介绍一下CSS自定义属性,有时候也被称作CSS变量或者级联变量。它包含的值可以在整个文档中重复使用。自定义属性使用 --变量名:变量值来定义,用var(--变量名[,默认值]) 函数来获取值。举一个简单例子:

<!--html-->
<div><p>text</p></div>

/\* css \*/
div { --my-color: red; border: 1px solid var(--my-color); }
p { color: var(--my-color); }

这时候div的边框和内部的p元素就能使用这个定义的变量来设置自己的颜色。

通常CSS自定义属性需要定义在元素内,通过在:root伪类上设置自定义属性,可以在整个文档需要的地方使用。CSS变量是可以继承的,也就是说我们可以通过CSS继承创建一些局部主题,这里就不展开局部主题的讨论,我们只需要使用好:root伪类就能对整站实施主题化了。

如何切换主题呢,我们在运行的时候给头部插入一段<style>:root{--变量1: 色值1;--变量2: 色值2 ;……}</style>,并通过id或者引用的方式保持对该style元素的引用,通过修改style元素innerText为 :root{--变量1: 色值3; --变量2: 色值4;……}就可以成功替换变量颜色了。

由于主题数据可能是从接口等其他地方获取的,我们可以在使用的地方给它先加上默认值,避免主题数据到达之前出现没有颜色的现象,比如 p { color: var(--变量1,色值1);}这样,就使用上了css自定义属性来在运行时动态加载不同的主题颜色值。

Sass/Less支持

如果直接在开发css中使用css变量很容易由于书写问题,定义问题最后导致变量众多,管理困难,变更默认色值替换成本高等问题。在大型网站的开发中通常会用sass/less来预定义一些颜色变量来进行色彩管理。

在使用sass和less的时候可以改变原来的传递色值方式改为传递css自定义属性和默认值。color定义文件:

image.png

这里有个副作用就是,一旦色值被定义为var变量,则这个var表达式就无法再被less/sass的色彩计算函数所计算使用,这块我们在后面的章节再进行讨论。

定义完对应的变量之后, 使用的地方就可以直接使用使用这些变量,方便统一管理。

使用媒体查询

prefer-color-scheme是浏览器获取系统上用户对颜色主题的倾向性的css api,使用该api我们就可以轻松使得网站的主题跟随系统的颜色设置展示不同的颜色了。

css的API如下:

// css
@media (prefers-color-scheme: light) {
  :root{--变量1: 色值1;--变量2: 色值2; ……}
}
@media (prefers-color-scheme: dark) {
  :root{--变量1: 色值3; --变量2: 色值4; ……}
}

脚本方面也有对应的媒体查询方案,js的API如下:

// js
function isDarkSchemePreference(){
  return window.matchMedia('screen and (prefers-color-scheme: dark)').matches;
}

主题切换服务

最后我们需要写一个主题服务,主要目的就是支持在切换主题的时候应用不同的css变量数据,假定我们的css变量的数据存储在一个对象里,key值为css变量名,value值为css变量在该主题下的值,那么我们的主题切换服务的关键核心函数如下:

// theme.ts
export class Theme {
  id: ThemeId;
  name: string;
  data: {
    \[cssVarName: string\]: string
  };
}

// theme-service.ts
class ThemeService {
  contentElement;
  eventBus;
  // ……
  applyTheme(theme: Theme) {
    this.currentTheme = theme;
    if (!this.contentElement) {
      const styleElement = document.getElementById('devuiThemeVariables');
      if ( styleElement) {
        this.contentElement = <HTMLStyleElement>styleElement;
      } else {
        this.contentElement = document.createElement('style');
        this.contentElement.id = 'devuiThemeVariables';
        document.head.appendChild(this.contentElement);
      }
    }
    this.contentElement.innerText = ':root { ' + this.formatCSSVariables(theme.data) + ' }';
    document.body.setAttribute('ui-theme', this.currentTheme.id);

    // 通知外部主题变更
    this.notify(theme, 'themeChanged');
  }

  formatCSSVariables(themeData: Theme\['data'\]) {
    return Object.keys(themeData).map(
      cssVar => ('--' + cssVar + ':' + themeData\[cssVar\])
    ).join(';');
  }

  private notify(theme: Theme, eventType: string) {
    if (!this.eventBus) { return; }
    this.eventBus.trigger(eventType, theme);
  }
}

其中applyTheme函数会创建一个style元素,如果已经创建好了则直接改变style的内容。如果要支持跟随系统还需要一些额外函数的判断,这里就不展开了,可以参考链接,原理是通过动画结束事件监听媒体查询变化,对应可以使用enquirejs库。

至此我们打通了主题服务和css变量值在开发中的应用,下面就可以开发一个深色模式了。

深色模式开发

语义化色彩变量

深色模式涉及到了大量网站视觉的“反色”,在已有的网站当中,应该好好排查和梳理网站的颜色,把颜色归一和约束到一定的变量范围和数量里,并给颜色的不同使用场景一个不同的语义变量名,这样能取得场景分离的效果。

从文本颜色上我们举个简单的例子:

通常的网站里都会有正文(主要文本),帮助提示信息(次要文本),文本占位符。这里我们可以使用三个变量来描述这些文本text-color-primary,text-color-secondary,text-color-tertiary,也可以使用text-color-normal,text-color-help-info,text-color-placeholder来描述这这些颜色值。

这里强烈建议使用更有语义的变量而不是色值本身的描述,比如:错误背景色,应该使用background-color-danger而不是background-color-red,因为对于不同的主题颜色值可能是不一样的。

图1 语义化变量示意

使用统一语义变量控制组件表现

需要定义多少的变量才恰当,这个取决于网站的色彩空间约束范围和使用场景的定义粒度。当定义了一套变量之后我们就可以对组件/网站的不同组成部分进行变量统一。

比如搜索框和下拉框,使用同样的变量控制相同部分的表现,使得组件在主题变化的可以使用相同的颜色规则。

图2 使用变量对组件进行规约

提供暗黑主题色值

完成了上面重要的两步,我们就可以通过给变量提供一套新的色值来达到主题的变化了。

图3 通过色值的切换实现深色主题切换

图片的处理

图片的处理并不能像文字一样地去反转颜色或者反转亮度,这样可能照成不适。通常如果有准备亮色和暗色两套图片,可以采用变量化图片地址在不同主题下切黑图片。如果图片来自用户输入,其他地方的截图,这时候需要稍微处理一些降低亮度。图片简化地获取当前的主题状态可以在body上增加一个ui主题是否是深色模式的属性。

深色方案一:图片增加透明度。适用场景:简单文章图片和纯色背景。

// css
body\[ui-theme-mode='dark'\] img {
  opacity: 0.8;
}

深色方案二:带图片的位置叠加一个灰色半透明的层,适用场景:背景图,非纯色背景等。

// css
body\[ui-theme-mode='dark'\] .dark-mode-image-overlay {
  position: relative;
}
body\[ui-theme-mode='dark'\] .dark-mode-image-overlay::before {
  content: '';
  display: block;
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(50, 50, 50, 0.5);
}

前者不适用与带有背景图片的层处理,也不适合通过叠加图片遮挡来呈现效果的处理,但是用在文章博客中的插入图片非常简单有效,图片可以自然地叠加到纯色深色的背景色上。后者给了另一种方案完成背景层的叠加,但对代码有一定的入侵。

提供主题变化订阅应对第三方组件场景

通过以上几个基本的步骤就能在编码的过程中通过使用变量指定颜色值,获得主题的能力。但是面对大量第三方组件,有自己的主题,也可能有自己的深色主题,这块再去入侵式地修改成自定义的变量工作量不小且并不一定合适。

这时候需要提供主题订阅,在主题发生变化的时候,获得通知,然后给第三方组件设置一定对应的变更。

我们需要一个简单的eventbus,实现方式不限。这里给出一个简单版本的接口如下:

// theme/interface.ts
export interface IEventBus {
  on(eventName: string, callbacks: Function): void;
  off(eventName: string, callbacks: Function): void;
  trigger(eventName: string, data: any): void;
}

切换主题的时候发出themeChanged事件,使用on监听就能够获得当前主题变更事件,通过判断主题,给第三方的组件套上对应的主题,或者修改js颜色变量等等。

降级支持和使用脚本腻子

降级PostCSS插值脚本

一旦使用了var之后,那些不支持var的老浏览器会显示为无颜色,这里我们使用postcss插件处理最后一个阶段的css。

// postcss-plugin-add-var-value.js
var postcss = require('postcss');
var cssVarReg = new RegExp('var\\\\\\\\(\\\\\\\\-\\\\\\\\-(?:.\*?),(.\*?)\\\\\\\\)', 'g');

module.exports = postcss.plugin('postcss-plugin-add-origin-css-var-value', () => {
  return (root) => {
    root.walkDecls(decl => {
      if (decl.type !== 'comment' && decl.value && decl.value.match(cssVarReg)) {
        decl.cloneBefore({value: decl.value.replace(cssVarReg, (match, item) => item) });
      }
    });
  }
});

该postcss插件通过遍历css规则里的带有var(--变量名, 变量值)在该行的上一行插入了一行替换为直接变量值的值,兼容不支持css var的浏览器。

image.png

css-vars-ponyfill 使 IE9+ 和 Edge 12+支持上主题切换

css-vars-ponyfill 这个npm包可以使得ie9+/edge12+支持上css自定义属性,它是一个带有选项的兼容方案,大概原理就是通过监听style里带有var自定义属性的值,替换为原值并插入。该兼容方案目前不兼容直接挂在在元素上的局部的css自定义属性定义。该方案还提供了实时监听style插入的选项,支持var链式的取值。简单地加入polyfill就可以使用了。

// polyfill.ts
import cssVars from 'css-vars-ponyfill';
cssVars({ watch: true, silent: true});

一些问题的探讨

什么网站需要开发深色模式?

深色模式适合长时间阅读、长时间沉浸式浏览的网站,包括新闻、博客、知识库等文章浏览和视频网站,开发IDE界面等沉浸式交互。这些网站使用深色模式可以通过降低亮度减少对眼睛的刺激,减少长时间浏览的疲惫和晕眩的感觉。

深色模式不适合一些非深色风格产品的展示,深沉的背景色会影响产品风格呈现、传递的情感和用户观看时候的心情,不适当的颜色搭配容易引起反感。像一些电商网站深色模式要慎重处理,深色可能会使得产品图片呈现的积极风格受到一定程度的抑制,颜色可能会影响用户的购物欲望。一些主题推广宣传类的网站也是,颜色可能会削弱主题的表达。

有没有更简单的深色模式映射切换?比如使用HSL替代RGB色值。

HSL色值的表达形式是通过色相、饱和度、亮度,既然深色模式是调整亮度和饱和度,那是否可以通过hsl色值来自动计算呢? 这种自动出暗色版本的色值还有待探索中,主要有两个原因:1)深色模式的舒适度不是线性亮度和饱和度映射能完成的,颜色的函数计算深色映射显得相对单调。2)实际情况是一个颜色可能会映射到多个暗黑场景的颜色。

针对第一点,目前有一些UI会推出非线性反色的算法,也是为了解决颜色一起调整亮度之后变得看不清、色彩反色后冲击过大的问题。这类的算法还有很多优化空间。在浅色搭配情况下可能很好看的颜色,放到深色下可能就会引起不舒适:不恰当的对比度会引起视觉上看不清晰;不恰当的色彩碰撞会引起反感;不恰当的饱和度、亮度会显得UI有点脏。

针对第二点,可以举以下的场景来说明:同样是白色,有色背景下的白色,在深色模式下可能还是保持白色;而作为背景色的白色在深色场景下会对应调整为深色。

图4 一种白色的存在切换主题的多种映射

此时,自动通过色值计算就需要区分颜色的周边颜色或者底层叠加颜色来计算,这无疑加大了计算难度。

所以这块自动计算并不太容易,还需要一些的探索。

Sass/Less使用var变量后变成字符串管理,无法对颜色进行变换计算?

本身sass/less的变量和css自定义属性就不是一套变量系统,sass/less的是一种编译型变量(编译时确定值,编译后不存在),而css是一个运行时变量(即运行时确定值)。用sass/less去管理css变量时为了管理css变量防止定义失误,但使用了Sass或Less之后替换成var之后会发现,sass和less是一些比如lightenfadeoutrgba等等的函数都无法使用了,因为对与sass和less来说,var(--xxx, #xxx)是一个字符串不是颜色值。这块目前也没有比较好的方法, 有一些文章也讨论了一些解法,如 链接,大体的思路是拆分颜色的表达为hsl形式,然后对颜色的维度进行操作处理,实际上还是不能无感知地使用内建的色彩变换函数。另一个解法/方案是:把涉及颜色变换的地方统一处理然后再赋予新的css变量名,不再在mixin等函数里对颜色进行变换而是对变量名进行规则变化。如果读者有其他较好的思路也可以在评论里分享。

总结

本文介绍了利用CSS自定义属性能够给css定义一些颜色变量,轻松地实现深色主题的开发甚至支持更多的主题化。通过色彩变量定义,使用变量,处理图片和处理三方组件支持实现整站的深色模式的规约和完善。进一步介绍了降级支持的方法,并对深色模式的适用范围和一些其他方式实现进行了讨论。

加入我们

我们是DevUI团队,欢迎来这里和我们一起打造优雅高效的人机设计/研发体系。招聘邮箱:muyang2@huawei.com。

文/DevUI rhlin

往期文章推荐

《浅谈前端中的圈复杂度》

《手把手教你搭建一个灰度发布环境》

《手把手教你使用Vue/React/Angular三大框架开发Pagination分页组件》

查看原文

命理难说 赞了文章 · 2020-05-25

Web界面深色模式和主题化开发

DevUI是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部数个中后台系统,服务于设计师和前端工程师。
官方网站:devui.design
Ng组件库:ng-devui(欢迎Star)
官方交流群:添加 DevUI小助手(微信号:devui-official)进群

引言

深色模式(Dark Mode)在iOS13 引入该特性后各大应用和网站都开始支持了深色模式。在这之前,深色模式更常见于程序IDE开发界面和视频网站界面。前者通过降低屏幕亮度,使得使用人员长时间盯着屏幕眼睛没有那么疲惫;后者通过深色模式来降噪,从而突出主体内容部分。快速开发一个深色模式难吗? 在支持css自定义属性(又称css变量,css variables)的现代浏览器里,可以说是相当的容易。甚至可以在运行时实时新增主题,摆脱传统css主题文件加载模式下的主题需要预编译内置不能随时修改的弊端。下面我们来看一下如何使用css自定义属性来完成深色模式和主题化的开发。

主题切换器开发

首先我们需要打通一套支持css自定义属性的开发模式。

CSS自定义属性使用

这里简单介绍一下CSS自定义属性,有时候也被称作CSS变量或者级联变量。它包含的值可以在整个文档中重复使用。自定义属性使用 --变量名:变量值来定义,用var(--变量名[,默认值]) 函数来获取值。举一个简单例子:

<!--html-->
<div><p>text</p></div>

/\* css \*/
div { --my-color: red; border: 1px solid var(--my-color); }
p { color: var(--my-color); }

这时候div的边框和内部的p元素就能使用这个定义的变量来设置自己的颜色。

通常CSS自定义属性需要定义在元素内,通过在:root伪类上设置自定义属性,可以在整个文档需要的地方使用。CSS变量是可以继承的,也就是说我们可以通过CSS继承创建一些局部主题,这里就不展开局部主题的讨论,我们只需要使用好:root伪类就能对整站实施主题化了。

如何切换主题呢,我们在运行的时候给头部插入一段<style>:root{--变量1: 色值1;--变量2: 色值2 ;……}</style>,并通过id或者引用的方式保持对该style元素的引用,通过修改style元素innerText为 :root{--变量1: 色值3; --变量2: 色值4;……}就可以成功替换变量颜色了。

由于主题数据可能是从接口等其他地方获取的,我们可以在使用的地方给它先加上默认值,避免主题数据到达之前出现没有颜色的现象,比如 p { color: var(--变量1,色值1);}这样,就使用上了css自定义属性来在运行时动态加载不同的主题颜色值。

Sass/Less支持

如果直接在开发css中使用css变量很容易由于书写问题,定义问题最后导致变量众多,管理困难,变更默认色值替换成本高等问题。在大型网站的开发中通常会用sass/less来预定义一些颜色变量来进行色彩管理。

在使用sass和less的时候可以改变原来的传递色值方式改为传递css自定义属性和默认值。color定义文件:

image.png

这里有个副作用就是,一旦色值被定义为var变量,则这个var表达式就无法再被less/sass的色彩计算函数所计算使用,这块我们在后面的章节再进行讨论。

定义完对应的变量之后, 使用的地方就可以直接使用使用这些变量,方便统一管理。

使用媒体查询

prefer-color-scheme是浏览器获取系统上用户对颜色主题的倾向性的css api,使用该api我们就可以轻松使得网站的主题跟随系统的颜色设置展示不同的颜色了。

css的API如下:

// css
@media (prefers-color-scheme: light) {
  :root{--变量1: 色值1;--变量2: 色值2; ……}
}
@media (prefers-color-scheme: dark) {
  :root{--变量1: 色值3; --变量2: 色值4; ……}
}

脚本方面也有对应的媒体查询方案,js的API如下:

// js
function isDarkSchemePreference(){
  return window.matchMedia('screen and (prefers-color-scheme: dark)').matches;
}

主题切换服务

最后我们需要写一个主题服务,主要目的就是支持在切换主题的时候应用不同的css变量数据,假定我们的css变量的数据存储在一个对象里,key值为css变量名,value值为css变量在该主题下的值,那么我们的主题切换服务的关键核心函数如下:

// theme.ts
export class Theme {
  id: ThemeId;
  name: string;
  data: {
    \[cssVarName: string\]: string
  };
}

// theme-service.ts
class ThemeService {
  contentElement;
  eventBus;
  // ……
  applyTheme(theme: Theme) {
    this.currentTheme = theme;
    if (!this.contentElement) {
      const styleElement = document.getElementById('devuiThemeVariables');
      if ( styleElement) {
        this.contentElement = <HTMLStyleElement>styleElement;
      } else {
        this.contentElement = document.createElement('style');
        this.contentElement.id = 'devuiThemeVariables';
        document.head.appendChild(this.contentElement);
      }
    }
    this.contentElement.innerText = ':root { ' + this.formatCSSVariables(theme.data) + ' }';
    document.body.setAttribute('ui-theme', this.currentTheme.id);

    // 通知外部主题变更
    this.notify(theme, 'themeChanged');
  }

  formatCSSVariables(themeData: Theme\['data'\]) {
    return Object.keys(themeData).map(
      cssVar => ('--' + cssVar + ':' + themeData\[cssVar\])
    ).join(';');
  }

  private notify(theme: Theme, eventType: string) {
    if (!this.eventBus) { return; }
    this.eventBus.trigger(eventType, theme);
  }
}

其中applyTheme函数会创建一个style元素,如果已经创建好了则直接改变style的内容。如果要支持跟随系统还需要一些额外函数的判断,这里就不展开了,可以参考链接,原理是通过动画结束事件监听媒体查询变化,对应可以使用enquirejs库。

至此我们打通了主题服务和css变量值在开发中的应用,下面就可以开发一个深色模式了。

深色模式开发

语义化色彩变量

深色模式涉及到了大量网站视觉的“反色”,在已有的网站当中,应该好好排查和梳理网站的颜色,把颜色归一和约束到一定的变量范围和数量里,并给颜色的不同使用场景一个不同的语义变量名,这样能取得场景分离的效果。

从文本颜色上我们举个简单的例子:

通常的网站里都会有正文(主要文本),帮助提示信息(次要文本),文本占位符。这里我们可以使用三个变量来描述这些文本text-color-primary,text-color-secondary,text-color-tertiary,也可以使用text-color-normal,text-color-help-info,text-color-placeholder来描述这这些颜色值。

这里强烈建议使用更有语义的变量而不是色值本身的描述,比如:错误背景色,应该使用background-color-danger而不是background-color-red,因为对于不同的主题颜色值可能是不一样的。

图1 语义化变量示意

使用统一语义变量控制组件表现

需要定义多少的变量才恰当,这个取决于网站的色彩空间约束范围和使用场景的定义粒度。当定义了一套变量之后我们就可以对组件/网站的不同组成部分进行变量统一。

比如搜索框和下拉框,使用同样的变量控制相同部分的表现,使得组件在主题变化的可以使用相同的颜色规则。

图2 使用变量对组件进行规约

提供暗黑主题色值

完成了上面重要的两步,我们就可以通过给变量提供一套新的色值来达到主题的变化了。

图3 通过色值的切换实现深色主题切换

图片的处理

图片的处理并不能像文字一样地去反转颜色或者反转亮度,这样可能照成不适。通常如果有准备亮色和暗色两套图片,可以采用变量化图片地址在不同主题下切黑图片。如果图片来自用户输入,其他地方的截图,这时候需要稍微处理一些降低亮度。图片简化地获取当前的主题状态可以在body上增加一个ui主题是否是深色模式的属性。

深色方案一:图片增加透明度。适用场景:简单文章图片和纯色背景。

// css
body\[ui-theme-mode='dark'\] img {
  opacity: 0.8;
}

深色方案二:带图片的位置叠加一个灰色半透明的层,适用场景:背景图,非纯色背景等。

// css
body\[ui-theme-mode='dark'\] .dark-mode-image-overlay {
  position: relative;
}
body\[ui-theme-mode='dark'\] .dark-mode-image-overlay::before {
  content: '';
  display: block;
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(50, 50, 50, 0.5);
}

前者不适用与带有背景图片的层处理,也不适合通过叠加图片遮挡来呈现效果的处理,但是用在文章博客中的插入图片非常简单有效,图片可以自然地叠加到纯色深色的背景色上。后者给了另一种方案完成背景层的叠加,但对代码有一定的入侵。

提供主题变化订阅应对第三方组件场景

通过以上几个基本的步骤就能在编码的过程中通过使用变量指定颜色值,获得主题的能力。但是面对大量第三方组件,有自己的主题,也可能有自己的深色主题,这块再去入侵式地修改成自定义的变量工作量不小且并不一定合适。

这时候需要提供主题订阅,在主题发生变化的时候,获得通知,然后给第三方组件设置一定对应的变更。

我们需要一个简单的eventbus,实现方式不限。这里给出一个简单版本的接口如下:

// theme/interface.ts
export interface IEventBus {
  on(eventName: string, callbacks: Function): void;
  off(eventName: string, callbacks: Function): void;
  trigger(eventName: string, data: any): void;
}

切换主题的时候发出themeChanged事件,使用on监听就能够获得当前主题变更事件,通过判断主题,给第三方的组件套上对应的主题,或者修改js颜色变量等等。

降级支持和使用脚本腻子

降级PostCSS插值脚本

一旦使用了var之后,那些不支持var的老浏览器会显示为无颜色,这里我们使用postcss插件处理最后一个阶段的css。

// postcss-plugin-add-var-value.js
var postcss = require('postcss');
var cssVarReg = new RegExp('var\\\\\\\\(\\\\\\\\-\\\\\\\\-(?:.\*?),(.\*?)\\\\\\\\)', 'g');

module.exports = postcss.plugin('postcss-plugin-add-origin-css-var-value', () => {
  return (root) => {
    root.walkDecls(decl => {
      if (decl.type !== 'comment' && decl.value && decl.value.match(cssVarReg)) {
        decl.cloneBefore({value: decl.value.replace(cssVarReg, (match, item) => item) });
      }
    });
  }
});

该postcss插件通过遍历css规则里的带有var(--变量名, 变量值)在该行的上一行插入了一行替换为直接变量值的值,兼容不支持css var的浏览器。

image.png

css-vars-ponyfill 使 IE9+ 和 Edge 12+支持上主题切换

css-vars-ponyfill 这个npm包可以使得ie9+/edge12+支持上css自定义属性,它是一个带有选项的兼容方案,大概原理就是通过监听style里带有var自定义属性的值,替换为原值并插入。该兼容方案目前不兼容直接挂在在元素上的局部的css自定义属性定义。该方案还提供了实时监听style插入的选项,支持var链式的取值。简单地加入polyfill就可以使用了。

// polyfill.ts
import cssVars from 'css-vars-ponyfill';
cssVars({ watch: true, silent: true});

一些问题的探讨

什么网站需要开发深色模式?

深色模式适合长时间阅读、长时间沉浸式浏览的网站,包括新闻、博客、知识库等文章浏览和视频网站,开发IDE界面等沉浸式交互。这些网站使用深色模式可以通过降低亮度减少对眼睛的刺激,减少长时间浏览的疲惫和晕眩的感觉。

深色模式不适合一些非深色风格产品的展示,深沉的背景色会影响产品风格呈现、传递的情感和用户观看时候的心情,不适当的颜色搭配容易引起反感。像一些电商网站深色模式要慎重处理,深色可能会使得产品图片呈现的积极风格受到一定程度的抑制,颜色可能会影响用户的购物欲望。一些主题推广宣传类的网站也是,颜色可能会削弱主题的表达。

有没有更简单的深色模式映射切换?比如使用HSL替代RGB色值。

HSL色值的表达形式是通过色相、饱和度、亮度,既然深色模式是调整亮度和饱和度,那是否可以通过hsl色值来自动计算呢? 这种自动出暗色版本的色值还有待探索中,主要有两个原因:1)深色模式的舒适度不是线性亮度和饱和度映射能完成的,颜色的函数计算深色映射显得相对单调。2)实际情况是一个颜色可能会映射到多个暗黑场景的颜色。

针对第一点,目前有一些UI会推出非线性反色的算法,也是为了解决颜色一起调整亮度之后变得看不清、色彩反色后冲击过大的问题。这类的算法还有很多优化空间。在浅色搭配情况下可能很好看的颜色,放到深色下可能就会引起不舒适:不恰当的对比度会引起视觉上看不清晰;不恰当的色彩碰撞会引起反感;不恰当的饱和度、亮度会显得UI有点脏。

针对第二点,可以举以下的场景来说明:同样是白色,有色背景下的白色,在深色模式下可能还是保持白色;而作为背景色的白色在深色场景下会对应调整为深色。

图4 一种白色的存在切换主题的多种映射

此时,自动通过色值计算就需要区分颜色的周边颜色或者底层叠加颜色来计算,这无疑加大了计算难度。

所以这块自动计算并不太容易,还需要一些的探索。

Sass/Less使用var变量后变成字符串管理,无法对颜色进行变换计算?

本身sass/less的变量和css自定义属性就不是一套变量系统,sass/less的是一种编译型变量(编译时确定值,编译后不存在),而css是一个运行时变量(即运行时确定值)。用sass/less去管理css变量时为了管理css变量防止定义失误,但使用了Sass或Less之后替换成var之后会发现,sass和less是一些比如lightenfadeoutrgba等等的函数都无法使用了,因为对与sass和less来说,var(--xxx, #xxx)是一个字符串不是颜色值。这块目前也没有比较好的方法, 有一些文章也讨论了一些解法,如 链接,大体的思路是拆分颜色的表达为hsl形式,然后对颜色的维度进行操作处理,实际上还是不能无感知地使用内建的色彩变换函数。另一个解法/方案是:把涉及颜色变换的地方统一处理然后再赋予新的css变量名,不再在mixin等函数里对颜色进行变换而是对变量名进行规则变化。如果读者有其他较好的思路也可以在评论里分享。

总结

本文介绍了利用CSS自定义属性能够给css定义一些颜色变量,轻松地实现深色主题的开发甚至支持更多的主题化。通过色彩变量定义,使用变量,处理图片和处理三方组件支持实现整站的深色模式的规约和完善。进一步介绍了降级支持的方法,并对深色模式的适用范围和一些其他方式实现进行了讨论。

加入我们

我们是DevUI团队,欢迎来这里和我们一起打造优雅高效的人机设计/研发体系。招聘邮箱:muyang2@huawei.com。

文/DevUI rhlin

往期文章推荐

《浅谈前端中的圈复杂度》

《手把手教你搭建一个灰度发布环境》

《手把手教你使用Vue/React/Angular三大框架开发Pagination分页组件》

查看原文

赞 13 收藏 9 评论 0

命理难说 关注了用户 · 2020-05-21

ssh_晨曦时梦见兮 @chenshimengjian

关注 43

命理难说 赞了文章 · 2020-05-21

Vue3 的响应式和以前有什么区别,Proxy 无敌?

前言

大家都知道,Vue2 里的响应式其实有点像是一个半完全体,对于对象上新增的属性无能为力,对于数组则需要拦截它的原型方法来实现响应式。

举个例子:

let vm = new Vue({
  data() {
    return {
        a: 1
    }
  }
})

// ❌  oops,没反应!
vm.b = 2 
let vm = new Vue({
  data() {
    return {
        a: 1
    }
  },
  watch: {
    b() {
      console.log('change !!')
    }
  }
})

// ❌  oops,没反应!
vm.b = 2

这种时候,Vue 提供了一个 api:this.$set,来使得新增的属性也拥有响应式的效果。

但是对于很多新手来说,很多时候需要小心翼翼的去判断到底什么情况下需要用 $set,什么时候可以直接触发响应式。

总之,在 Vue3 中,这些都将成为过去。本篇文章会带你仔细讲解,proxy 到底会给 Vue3 带来怎么样的便利。并且会从源码级别,告诉你这些都是如何实现的。

响应式仓库

Vue3 不同于 Vue2 也体现在源码结构上,Vue3 把耦合性比较低的包分散在 packages 目录下单独发布成 npm 包。 这也是目前很流行的一种大型项目管理方式 Monorepo

其中负责响应式部分的仓库就是 @vue/rectivity,它不涉及 Vue 的其他的任何部分,是非常非常 「正交」 的一种实现方式。

甚至可以轻松的集成进 React

这也使得本篇的分析可以更加聚焦的分析这一个仓库,排除其他无关部分。

区别

Proxy 和 Object.defineProperty 的使用方法看似很相似,其实 Proxy 是在 「更高维度」 上去拦截属性的修改的,怎么理解呢?

Vue2 中,对于给定的 data,如 { count: 1 },是需要根据具体的 key 也就是 count,去对「修改 data.count 」 和 「读取 data.count」进行拦截,也就是

Object.defineProperty(data, 'count', {
  get() {},
  set() {},
})

必须预先知道要拦截的 key 是什么,这也就是为什么 Vue2 里对于对象上的新增属性无能为力。

而 Vue3 所使用的 Proxy,则是这样拦截的:

new Proxy(data, {
  get(key) { },
  set(key, value) { },
})

可以看到,根本不需要关心具体的 key,它去拦截的是 「修改 data 上的任意 key」 和 「读取 data 上的任意 key」。

所以,不管是已有的 key 还是新增的 key,都逃不过它的魔爪。

但是 Proxy 更加强大的地方还在于 Proxy 除了 get 和 set,还可以拦截更多的操作符。

简单的例子🌰

先写一个 Vue3 响应式的最小案例,本文的相关案例都只会用 reactiveeffect 这两个 api。如果你了解过 React 中的 useEffect,相信你会对这个概念秒懂,Vue3 的 effect 不过就是去掉了手动声明依赖的「进化版」的 useEffect

React 中手动声明 [data.count] 这个依赖的步骤被 Vue3 内部直接做掉了,在 effect 函数内部读取到 data.count 的时候,它就已经被收集作为依赖了。

Vue3:

// 响应式数据
const data = reactive({ 
  count: 1
})

// 观测变化
effect(() => console.log('count changed', data.count))

// 触发 console.log('count changed', data.count) 重新执行
data.count = 2

React:

// 数据
const [data, setData] = useState({
  count: 1
})

// 观测变化 需要手动声明依赖
useEffect(() => {
  console.log('count changed', data.count)
}, [data.count])

// 触发 console.log('count changed', data.count) 重新执行
setData({
  count: 2
})

其实看到这个案例,聪明的你也可以把 effect 中的回调函数联想到视图的重新渲染、 watch 的回调函数等等…… 它们是同样基于这套响应式机制的。

而本文的核心目的,就是探究这个基于 Proxy 的 reactive api,到底能强大到什么程度,能监听到用户对于什么程度的修改。

先讲讲原理

先最小化的讲解一下响应式的原理,其实就是在 Proxy 第二个参数 handler 也就是陷阱操作符中,拦截各种取值、赋值操作,依托 tracktrigger 两个函数进行依赖收集和派发更新。

track 用来在读取时收集依赖。

trigger 用来在更新时触发依赖。

track

function track(target: object, type: TrackOpTypes, key: unknown) {
  const depsMap = targetMap.get(target);
  // 收集依赖时 通过 key 建立一个 set
  let dep = new Set()
  targetMap.set(ITERATE_KEY, dep)
  // 这个 effect 可以先理解为更新函数 存放在 dep 里
  dep.add(effect)    
}

target 是原对象。

type 是本次收集的类型,也就是收集依赖的时候用来标识是什么类型的操作,比如上文依赖中的类型就是 get,这个后续会详细讲解。

key 是指本次访问的是数据中的哪个 key,比如上文例子中收集依赖的 key 就是 count

首先全局会存在一个 targetMap,它用来建立 数据 -> 依赖 的映射,它是一个 WeakMap 数据结构。

targetMap 通过数据 target,可以获取到 depsMap,它用来存放这个数据对应的所有响应式依赖。

depsMap 的每一项则是一个 Set 数据结构,而这个 Set 就存放着对应 key 的更新函数。

是不是有点绕?我们用一个具体的例子来举例吧。

const target = { count: 1}
const data = reactive(target)

const effection = effect(() => {
  console.log(data.count)
})

对于这个例子的依赖关系,

  1. 全局的 targetMap 是:
targetMap: {
  { count: 1 }: dep    
}
  1. dep 则是
dep: {
  count: Set { effection }
}

这样一层层的下去,就可以通过 target 找到 count 对应的更新函数 effection 了。

trigger

这里是最小化的实现,仅仅为了便于理解原理,实际上要复杂很多,

其实 type 的作用很关键,先记住,后面会详细讲。

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
) {
  // 简化来说 就是通过 key 找到所有更新函数 依次执行
  const dep = targetMap.get(target)
  dep.get(key).forEach(effect => effect())
}

新增属性

这个上文已经讲了,由于 Proxy 完全不关心具体的 key,所以没问题。

// 响应式数据
const data = reactive({ 
  count: 1
})

// 观测变化
effect(() => console.log('newCount changed', data.newCount))

// ✅ 触发响应
data.newCount = 2

数组新增索引:

// 响应式数据
const data = reactive([])

// 观测变化
effect(() => console.log('data[1] changed', data[1]))

// ✅ 触发响应
data[1] = 5

数组调用原生方法:

const data = reactive([])
effect(() => console.log('c', data[1]))

// 没反应
data.push(1)

// ✅ 触发响应 因为修改了下标为 1 的值
data.push(2)

其实这一个案例就比较有意思了,我们仅仅是在调用 push,但是等到数组的第二项被 push的时候,我们之前关注 data[1] 为依赖的回调函数也执行了,这是什么原理呢?写个简单的 Proxy 就知道了。

const raw = []
const arr = new Proxy(raw, {
  get(target, key) {
    console.log('get', key)
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    console.log('set', key)
    return Reflect.set(target, key, value)
  }
})

arr.push(1)

在这个案例中,我们只是打印出了对于 raw 这个数组上的所有 get、set 操作,并且调用 Reflect 这个 api 原样处理取值和赋值操作后返回。看看 arr.push(1) 后控制台打印出了什么?

get push
get length
set 0
set length

原来一个小小的 push,会触发两对 get 和 set,我们来想象一下流程:

  1. 读取 push 方法
  2. 读取 arr 原有的 length 属性
  3. 对于数组第 0 项赋值
  4. 对于 length 属性赋值

这里的重点是第三步,对于第 index 项的赋值,那么下次再 push,可以想象也就是对于第 1 项触发 set 操作。

而我们在例子中读取 data[1],是一定会把对于 1 这个下标的依赖收集起来的,这也就清楚的解释了为什么 push 的时候也能精准的触发响应式依赖的执行。

对了,记住这个对于 length 的 set 操作,后面也会用到,很重要。

遍历后新增

// 响应式数据
const data = reactive([])

// 观测变化
effect(() => console.log('data map +1', data.map(item => item + 1))

// ✅ 触发响应 打印出 [2]
data.push(1)

这个拦截很神奇,但是也很合理,转化成现实里的一个例子来看,

假设我们要根据学生 id 的集合 ids, 去请求学生详细信息,那么仅仅是需要这样写即可:

const state = reactive({})
const ids = reactive([1])

effect(async () => {
  state.students = await axios.get('students/batch', ids.map(id => ({ id })))
})

// ✅ 触发响应 
ids.push(2)

这样,每次调用各种 api 改变 ids 数组,都会重新发送请求获取最新的学生列表。

如果我在监听函数中调用了 map、forEach 等 api,

说明我关心这个数组的长度变化,那么 push 的时候触发响应是完全正确的。

但是它是如何实现的呢?感觉似乎很复杂啊。

因为 effect 第一次执行的时候, data 还是个空数组,怎么会 push 的时候能触发更新呢?

还是用刚刚的小测试,看看 map 的时候会发生什么事情。

const raw = [1, 2]
const arr = new Proxy(raw, {
  get(target, key) {
    console.log('get', key)
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    console.log('set', key)
    return Reflect.set(target, key, value)
  }
})

arr.map(v => v + 1)
get map
get length
get constructor
get 0
get 1

和 push 的部分有什么相同的?找一下线索,我们发现 map 的时候会触发 get length,而在触发更新的时候, Vue3 内部会对 「新增 key」 的操作进行特殊处理,这里是新增了 0 这个下标的值,会走到 trigger 中这样的一段逻辑里去:

源码地址

// 简化版
if (isAddOrDelete) {
  add(depsMap.get('length'))
}

把之前读取 length 时收集到的依赖拿到,然后触发函数。

这就一目了然了,我们在 effect 里 map 操作读取了 length,收集了 length 的依赖。

在新增 key 的时候, 触发 length 收集到的依赖,触发回调函数即可。

对了,对于 for of 操作,也一样可行:

// 响应式数据
const data = reactive([])

// 观测变化
effect(() => {
  for (const val of data) {
    console.log('val', val)
  }
})

// ✅ 触发响应 打印出 val 1
data.push(1)

可以按我们刚刚的小试验自己跑一下拦截, for of 也会触发 length 的读取。

length 真是个好同志…… 帮了大忙了。

遍历后删除或者清空

注意上面的源码里的判断条件是 isAddOrDelete,所以删除的时候也是同理,借助了 length 上收集到的依赖。

// 简化版
if (isAddOrDelete) {
  add(depsMap.get('length'))
}
const arr = reactive([1])
  
effect(() => {
  console.log('arr', arr.map(v => v))
})

// ✅ 触发响应 
arr.length = 0

// ✅ 触发响应 
arr.splice(0, 1)

真的是什么操作都能响应,爱了爱了。

获取 keys

const obj = reactive({ a: 1 })
  
effect(() => {
  console.log('keys', Reflect.ownKeys(obj))
})

effect(() => {
  console.log('keys', Object.keys(obj))
})

effect(() => {
  for (let key in obj) {
    console.log(key)
  }
})

// ✅ 触发所有响应 
obj.b = 2

这几种获取 key 的方式都能成功的拦截,其实这是因为 Vue 内部拦截了 ownKeys 操作符。

const ITERATE_KEY = Symbol( 'iterate' );

function ownKeys(target) {
    track(target, "iterate", ITERATE_KEY);
    return Reflect.ownKeys(target);
}

ITERATE_KEY 就作为一个特殊的标识符,表示这是读取 key 的时候收集到的依赖。它会被作为依赖收集的 key。

那么在触发更新时,其实就对应这段源码:

if (isAddOrDelete) {
    add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY));
}

其实就是我们聊数组的时候,代码简化掉的那部分。判断非数组,则触发 ITERATE_KEY 对应的依赖。

小彩蛋:

Reflect.ownKeysObject.keysfor in 其实行为是不同的,

Reflect.ownKeys 可以收集到 Symbol 类型的 key,不可枚举的 key。

举例来说:

var a = {
  [Symbol(2)]: 2,
}

Object.defineProperty(a, 'b', {
  enumerable: false,
})

Reflect.ownKeys(a) // [Symbol(2), 'b']
Object.keys(a) // []

回看刚刚提到的 ownKeys 拦截,

function ownKeys(target) {
    track(target, "iterate", ITERATE_KEY);
    // 这里直接返回 Reflect.ownKeys(target)
    return  Reflect.ownKeys(target);
}

内部直接之间返回了 Reflect.ownKeys(target),按理来说这个时候 Object.keys 的操作经过了这个拦截,也会按照 Reflect.ownKeys 的行为去返回值。

然而最后返回的结果却还是 Object.keys 的结果,这是比较神奇的一点。

删除对象属性

有了上面 ownKeys 的基础,我们再来看看这个例子

const obj = reactive({ a: 1, b: 2})
  
effect(() => {
  console.log(Object.keys(obj))
})

// ✅ 触发响应 
delete obj['b']

这也是个神奇的操作,原理在于对于 deleteProperty 操作符的拦截:

function deleteProperty(target: object, key: string | symbol): boolean {
  const result = Reflect.deleteProperty(target, key)
  trigger(target, TriggerOpTypes.DELETE, key)
  return result
}

这里又用到了 TriggerOpTypes.DELETE 的类型,根据上面的经验,一定对它有一些特殊的处理。

其实还是 trigger 中的那段逻辑:

const isAddOrDelete = type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE
if (isAddOrDelete) {
  add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY))
}

这里的 target 不是数组,所以还是会去触发 ITERATE_KEY 收集的依赖,也就是上面例子中刚提到的对于 key 的读取收集到的依赖。

判断属性是否存在

const obj = reactive({})

effect(() => {
  console.log('has', Reflect.has(obj, 'a'))
})

effect(() => {
  console.log('has', 'a' in obj)
})

// ✅ 触发两次响应 
obj.a = 1

这个就很简单了,就是利用了 has 操作符的拦截。

function has(target, key) {
  const result = Reflect.has(target, key);
  track(target, "has", key);
  return result;
}

性能

  1. 首先 Proxy 作为浏览器的新标准,性能上是一定会得到厂商的大力优化的,拭目以待。
  2. Vue3 对于响应式数据,不再像 Vue2 中那样递归对所有的子数据进行响应式定义了,而是再获取到深层数据的时候再去利用 reactive 进一步定义响应式,这对于大量数据的初始化场景来说收益会非常大。

比如,对于

const obj = reactive({
  foo: {
    bar: 1
  }
})

初始化定义 reactive 的时候,只会对 obj 浅层定义响应式,而真正读取到 obj.foo 的时候,才会对 foo 这一层对象定义响应式,简化源码如下:

function get(target: object, key: string | symbol, receiver: object) {
  const res = Reflect.get(target, key, receiver)
  // 这段就是惰性定义
  return isObject(res)
    ? reactive(res)
    : res
}

推荐阅读

其实 Vue3 对于 MapSet 这两种数据类型也是完全支持响应式的,对于它们的原型方法也都做了完善的拦截,限于篇幅原因本文不再赘述。

说实话 Vue3 的响应式部分代码逻辑分支还是有点过多,对于代码理解不是很友好,因为它还会涉及到 readonly 等只读化的操作,如果看完这篇文章你对于 Vue3 的响应式原理非常感兴趣的话,建议从简化版的库入手去读源码。

这里我推荐 observer-util,我解读过这个库的源码,和 Vue3 的实现原理基本上是一模一样!但是简单了很多。麻雀虽小,五脏俱全。里面的注释也很齐全。

当然,如果你的英文不是很熟练,也可以看我精心用 TypeScript + 中文注释基于 observer-util 重写的这套代码:
typescript-proxy-reactive

对于这个库的解读,可以看我之前的两篇文章:

带你彻底搞懂Vue3的Proxy响应式原理!TypeScript从零实现基于Proxy的响应式库。

带你彻底搞懂Vue3的Proxy响应式原理!基于函数劫持实现Map和Set的响应式

在第二篇文章里,你也可以对于 Map 和 Set 可以做什么拦截操作,获得源码级别的理解。

总结

Vue3 的 Proxy 真的很强大,把 Vue2 里我认为心智负担很大的一部分给解决掉了。(在我刚上手 Vue 的时候,我是真的不知道什么情况下该用 $set),它的 composition-api 又可以完美对标 React Hook,并且得益于响应式系统的强大,在某些方面是优胜于它的。精读《Vue3.0 Function API》

希望这篇文章能在 Vue3 正式到来之前,提前带你熟悉 Vue3 的一些新特性。

扩展阅读

Proxy 的拦截器里有个 receiver 参数,在本文中为了简化没有体现出来,它是用来做什么的?国内的网站比较少能找到这个资料:

new Proxy(raw, {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver)
  }
})

可以看 StackOverflow 上的问答:what-is-a-receiver-in-javascript

也可以看我的总结
Proxy 和 Reflect 中的 receiver 到底是什么?

广告时间

优秀的小册作者修言大佬为前端想学算法的小伙伴们推出了一本零基础也能入门的算法小册,帮助你掌握一些基础算法核心思想或简单算法问题,这本小册我参与了内测过程,也给修言大大提出了很多意见。他的目标就是做面向算法零基础前端人群的「保姆式服务」,非常贴心了~

求点赞

如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我持续进行创作的动力,让我知道你喜欢看我的文章吧~

❤️感谢大家

关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

查看原文

赞 6 收藏 3 评论 2

命理难说 赞了文章 · 2020-05-06

你不知道的CSS国际化

CSS.jpg

我遇到过一些人,他们根本不认为CSS与国际化有关,但如果你仔细想想,国际化不仅仅是把你网站上的内容翻译成多种语言,然后就收工了。该内容的呈现方式有各种细微的差别,这些细微的差别会影响到母语人士使用您的网站的体验。

对于国际化,没有统一的规范定义,但是W3C提供以下指导:

国际化是指在设计和开发产品、应用或文档时,为不同文化、地区或语言的目标受众提供方便的本地化服务。

这涉及的内容很多,从Unicode和字符编码的使用,到服务于翻译内容的技术实现,以及上述内容的呈现方式,都有很多内容要涉及。今天,我只讨论与多语言支持有关的CSS相关方面。

CSS通过告诉浏览器应该如何设置样式和布局来描述网页的表示。我们可以使用多种方法在具有CSS的多语言页面上将不同的样式应用于不同的语言。

此外,还有一些CSS属性为文字和书写系统提供了布局和排版功能,这些功能超出了目前在web上常见的基于拉丁语的水平自顶向下的功能。

因此,请系好安全带,因为这可能最终是一篇冗长的文章。


语言相关样式

你有没有想过,Chrome浏览器是怎么知道问你要不要翻译网页内容的?这是因为 <html> 元素上的 lang 属性。

lang 属性是一个非常重要的属性,因为它标识web上文本内容的语言,而且这种信息在许多地方都被使用。上面提到的Chrome的内置翻译,针对特定语言的内容的搜索引擎以及屏幕阅读器。

也许你没有想到屏幕阅读器,但如果你不是屏幕阅读器的用户,或者你不认识屏幕阅读器的用户,你可能不会想到屏幕阅读器。屏幕阅读器使用语言信息,因此可以以适当的口音和正确的发音读出内容。

语言相关的风格设计的关键在于在页面标记中正确使用 lang 属性,lang属性可以识别ISO 639 language codes作为值。

在大多数情况下,你会使用像 zh 这样的两个字母代码来表示中文,但中文(在其他语言中,如阿拉伯语)被认为是由许多语言组成的大语言,其中有更多的主语子标记。

有关如何构造语言标签的详细说明,请参考HTML和XML中的语言标签

一般指导原则是 html 元素必须始终具有 lang 属性集,然后该属性将被所有其他元素继承。

<html lang="zh">

在同一页面上看到不同语言的内容并不罕见。在这种情况下,您可以使用 <span><div> 包装该内容,并将正确的 lang 属性应用于该包装元素。

<p>The fourth animal in the Chinese Zodiac is Rabbit (<span lang="zh">兔子</span>).</p>

现在我们已经弄清楚了这一点,下面的技术将假定 lang 属性已经被负责任地实现。

:lang() 伪类选择器

结果发现 :lang() 伪类选择器并不那么出名。但是,此伪类选择器非常酷,因为即使在元素外部声明了语言,它也可以识别内容的语言。

例如,一行标记有两种语言:

<p>
  We use <em>italics</em> 
    to emphasise words in English, 
  <span lang="zh">但是中文则是用<em>着重号</em></span>.
</p>

可以使用以下样式:

em:lang(zh) {
  font-style: normal;
  text-emphasis: dot;
}

如果你的浏览器支持 text-emphasis CSS属性,你应该可以看到在 <em> 中的每一个中文字符上添加强调符号(传统上用于强调东亚文字的排版符号),Chrome浏览器需要 -webkit- 前缀。

但问题是,lang 属性不是应用在 <em> 元素上,而是应用在它的父类上。伪类仍然可以使用,如果我们使用更常见的属性选择器,例如 [lang="zh],那么这个属性必须在 <em> 元素上才能生效。

使用属性选择器

这就引出了我们的下一个技术,使用属性选择器。这让我们可以选择具有特定属性的元素或具有特定值的属性。

匹配属性选择器的方法有七种,但是我只讨论那些我认为与 lang 属性更相关的方法。我所有的示例都使用中文作为目标语言,因此使用 zh 及其变体。

首先,我们可以使用以下语法完全匹配 lang 属性值:

[lang="zh"]
/* 只匹配zh */

我在前面提到过,中文被认为是一种宏语言,这意味着它的语言标签可以用额外的特殊性来组成,比如说文字子标签 HansHant(W3C说只有在必要时才用文字子标签来区分你所需要的,否则不要用),地区子标签 HKTW 等等。

重点是,语言标签可以不只是两个字母,而是可以长一些。但最广义的类别永远是第一位的,因此,要以特定字符串开头的属性值为目标,我们使用这个 ^ 语法开头。

[lang^="zh"]
/* 将匹配 zh, zh-HK, zh-Hans, zhong, zh123… 前两个字 */

还有另一种涉及到 | 的语法,它将与选择器中的确切值匹配,或者与紧跟在 - 后面的值开始匹配。好像是为了语言子代码匹配而设计的,不是吗?

[lang|="zh"]
/* 将匹配 zh, zh-HK, zh-Hans, zh-amazing, zh-123 */

请记住,对于属性选择器,该属性必须位于要设置样式的元素上,如果该属性在父项或祖先项上将不起作用。

普通的类或ID呢?

是的,你可以使用普通的类或id,虽然你将不再利用已经在你的元素上的便利。但是,可以肯定的是,如果确实愿意,为你的元素提供用于应用特定语言相关样式的类名,没有人会阻止你。

CSS属性

好了,选择器已经涵盖了。让我们来谈谈我们希望应用到与这些选择器相匹配的元素的样式。

Writing mode

writing-mode 的默认值为 horizontal-tb,完全合乎逻辑,因为网络诞生于CERN,官方语言为英语和法语。而且,无论如何,大多数网络技术都是在英语国家开创的。

但是人类的奇妙给了我们3000多种书写语言,它们的文字和书写方向超越了从上到下的水平方向。

传统的蒙古文字是从左至右垂直运行的,而东亚语言(如日语,中文和韩语)在垂直书写时,则是从右至左运行的。允许你这样做的writing-mode 属性分别是 vertical-lrvertical-rl

还有 sideways-lrsideways-rl 的值,它们使字形向侧面旋转。每个Unicode字符都有一个垂直方向属性,该属性会通知渲染引擎默认情况下字形的方向。

我们可以使用 text-orientation 属性更改字符的方向。当您在垂直排版的东亚文本中插入基于拉丁语的字词或字符时,通常会起作用。对于缩略语,您可以选择使用 text-combine-upright 的方式将字母压缩到一个字符空间。

有些人可能想知道从右到左的语言,如阿拉伯语、希伯来语或波斯语(仅举几例),以及CSS是否也适用于这些文字。

简而言之,CSS不应该用于双向风格设计。W3C的指南如下:

由于方向性是文档结构的一个组成部分,因此应使用标记来设置文档或信息块的方向性,或确定文本中仅靠Unicode双向算法不足以实现所需方向性的地方。

通过CSS应用此样式可能会被关闭,被覆盖,无法识别或在不同的上下文中被更改/替换。相反,建议使用 dir 属性来设置文字的基本显示方向。

逻辑属性

网页上的所有内容都是一个盒子,CSS始终使用topbottomleftright 的物理方向来指示我们要定位盒子的哪一侧。但是,当 writing-mode 的方向不是默认的从上到下的水平方向时,这些值会引起混淆。

盒子的物理侧和定位用的逻辑侧的书写方向矩阵及其对应值如下(从撰写本文时起,表格已从规格中删除):

容器的逻辑顶部使用 inset-before,而容器的逻辑底部使用inset-after。容器的逻辑左使用 inset-start,而容器的逻辑右使用 inset-end

也有相应的border、margin和padding的映射,分别是:

  • top to block-start
  • right to inline-end
  • bottom to block-end
  • left to inline-start

而在尺寸上的映射如下:widthinline-sizeheightblock-size 的映射。

列表和计数器

数字系统是用来表达数字的书写系统,即使最常用的数字系统是印度教阿拉伯数字系统(0、1、2、3等等),CSS也允许我们用其他数字系统来显示有序列表。

预定义的计数器样式可以使用 list-style-type 属性,它涵盖了从 afarurdu 的174个数字系统。你可以在MDN中查看完整的列表。

如果您对CSS计数器感兴趣,我在去年的某个时候写了关于它们的文章,其中探讨了在繁体中文上下文中使用的“ Heavenly-stem”和“ Earthly-branch”数字系统(以及CSS中的Fizzbuzz实现,因为为什么不)。

文本装饰

如前所述,东亚语言没有斜体的概念。相反,我们有着重点,可以将它们放置在字符上方或下方以强调文字,增强语气或避免歧义。

在以水平书写模式书写中文时,这些点位于字符上方,而在以垂直书写模式书写时,这些点位于字符左侧。

为了使CSS属性更具通用性,在CSS文本装饰模块 Level 3)中引入了文本强调样式,文本强调位置和文本强调颜色。

您可以使用除点以外的其他符号,例如 circle, triangle或单个字符作为字符串,位置和颜色也可以根据其各自的属性进行调整。

同一规范中还涵盖了行装饰,并为开发人员提供了对下划线和上划线的更精细控制(在规范的 level 4 中)。但是这对于那些有上升线或下行线的文字来说特别有用,因为它们经常会溢出基线。

CSS文本修饰模块第4级介绍了 text-decoration-skip,该控件控制跨过字形的上划线和下划线的绘制方式。再有,某些事情在英语等语言中发生的频率较低,但是在很大程度上影响了诸如缅甸语这样的文字的美观性。

字体变化

有两类用于访问OpenType功能的CSS属性,即高级属性和低级属性。规范建议尽可能使用高级属性。这主要取决于浏览器的支持。

例如,font-variant-east-asian 允许控制具有变体的字符的字形形式,例如简体中文字形与繁体中文字形。它是同一字符,但写法可能不同。

还有一种 font-variant-ligatures(变体连字),它提供了许多预设的字型和上下文形式的选项,如自由 discretionary-ligatureshistorical-ligaturescontextual

可通过 font-feature-settings 访问低级属性,你可以在其中使用4个字母的OpenType标记来切换所需的功能(这取决于你的字体是否具有这些功能开头,但我们假设它具有这些功能) 。

有141个特征标签,从可选的分数到对齐,从可选的Ruby表示法到割零。这些CSS属性与字体文件本身的功能密切相关,因此,外部依赖性取决于你选择的字体。

结束

这文章子真的很长,所以我将有第二部分来详细介绍我们如何使用我们所涉及的选择器来建立一个布局,以确保我们的布局即使在语言变化的情况下也能保持稳健。像Flexbox和Grid这样的现代布局属性很适合这样的用例。

关于CSS,我觉得最有趣的一点是,我们可以通过不同的方式将它们结合起来,以达到无数种结果,而目前有500多种CSS属性,这就有了很多的可能性。我并不是说什么都可以,因为很多时候,有无数种方法可以达到同样的结果,而且有些方法比其他方法更合适。

然而,通过了解每种技术背后的机制、它的优点和缺点,并了解我们为什么选择某种方式来做事情,我们需要做出明智的决定,以确定哪种方法最适合我们的环境。

我仍然相信,在30多年后,网络仍然是信息媒介,内容是关键。因此,无论使用何种语言或文字,内容的表现形式都应该得到优化。我很高兴的是,CSS正在不断发展,为开发者提供了实现这一目标的方法。

无论如何,请继续关注第2部分。


原文信息:


文章首发《前端外文精选》微信公众号

1.png

如果对你有所启发和帮助,可以点个关注、收藏、转发,也可以留言讨论,这是对作者的最大鼓励。

作者简介:Web前端工程师,全栈开发工程师、持续学习者。

继续阅读其他高赞文章


查看原文

赞 31 收藏 22 评论 0

命理难说 收藏了文章 · 2020-04-17

发现算法之美-时间复杂度

时间复杂度.jpg
正式工作也有3年的时间了,想要写出更加优雅的代码。

所以最近在刷leetcode补充数据结构和算法方面的知识。

学校里虽然学过,但是仅仅是有个大概的认识。只有实际工作过几年以后,才会明白数据结构和算法的重要性。

如果是通信专业出身的同学,或者是硬件出身的同学一定知道:对于一个信号,我们可以从时域和频域两个方面去分析。

那么计算机科学或者说软件开发中的算法怎么去分析呢?
有两个衡量优劣的维度:时间复杂度和空间复杂度。

  • 时间复杂度:执行当前算法所消耗的'时间'。
  • 空间复杂度:执行当前算法所占用的内存。

在这边博文中,我们来好好分析一下时间复杂度。

  • 时间复杂度真的是计算'时间'吗?
  • 时间复杂度公式:大O符号表示法
  • 常见时间复杂度类型及代码分析

    • 常数型O(1)
    • 对数型O(log n)
    • 线性型O(n)
    • 线性对数型O(n log n)
    • 平方型O(n^2)、立方型O(n^3)、K次方型O(n^k)
    • 平方底指数型O(2^n)、立方底指数型O(3^n)、K次底指数型O(k^n)
    • 阶乘型O(n!)
  • 如何理解斐波那契数列的时间复杂度O(2^N)?
  • 如何理解阶乘型时间复杂度O(n!)?
  • 参考资料

时间复杂度真的是计算'时间'吗?

把算法的执行时间当做时间复杂度?
这种方式是最为直观也是最容易想到的方式。
但是有一个问题,那就是代码在不同性能的机器上运行,以及在不同的状态下运行,会呈现出完全不同的运行时间。
比如说我有一台内存为32GB内存的mbp,还有一台8GB的台式机,假设其它的硬件条件比如cpu,主板以及机器负载状态一致。通常情况下,32GB的内存要比8GB的内存运行更快。而且这种理想状态下的只有单一变量的状态也是很难做到的。
所以不能通过计算算法的消耗时间作为时间复杂度。

那我们通常所说的'时间'复杂度中的'时间'到底是指什么呢?

聪明的前辈们想到了一种方式:大O表示法。

大O表示法内部有非常复杂的数学计算逻辑,我们偷个懒,不去证明公式,把公式用好就很厉害了。
为什么不去证明一下或者演算一遍?
我在大一曾经上过一门叫做高等代数的课,有道题目叫做:请证明1+1=2
看到这个题目应该知道为什么不深究大O表示法背后的数学了吧。

时间复杂度公式:大O符号表示法

T(n) = O(f(n))
  • f(n)是指每行代码执行次数之和
  • f(n)可以是这些值:1,log n,n,nlog n,n^2,n^3,n^k,2^n,n!
  • f(n)与O正相关
  • O(f(n))可以是这些值:O(1),O(log n),O(n),O(nlog n),O(n^2),O(n^3),O(n^k),O(2^n),O(n!)
  • 大O表示法实际表示的是代码执行时间的增长变化趋势,不是真实的运行时间,是一种趋势预估
  • 大O表示法中的f(n)是近似值。很多时候不会完全是1,log n,n,nlog n,n^2,n^3,n^k,2^n,n!这些完整的值。例如斐波那契数列的真实时间复杂度为O(2^N-1),由于N->∞,所以可以近似为O(2^N)。

更多的斐波那契数列时间复杂度的分析可以查看下文中的:如何理解斐波那契数列的时间复杂度O(2^N)?

常见时间复杂度类型及代码分析

理论扯了一大堆了,到精彩绝伦的Show me the code环节了。
先来看一张大O复杂度曲线图。

image

以下时间复杂度根据最佳->较好->一般->较差->糟糕的顺序排列。

  • 常数型O(1)
  • 对数型O(log n)
  • 线性型O(n)
  • 线性对数型O(n log n)
  • 平方型O(n^2)、立方型O(n^3)、K次方型O(n^k)
  • 平方底指数型O(2^n)、立方底指数型O(3^n)、K次底指数型O(k^n)
  • 阶乘型O(n!)

常数型O(1)

  • 常见于赋值和引用等简单操作
  • 算法消耗不随变量增长而增长,性能最佳
  • 无论代码执行多少行,即使有几千几万行,时间复杂度都为O(1)
  • 实际开发过程中,一次递归的时间复杂度也为O(1)。因为O(1^n)无论n为多少都为O(1)
let i = 0;
let j = 9;
i++;
j--;
let k = i + j;

代码分析:
i为1,j为10,k为11。
时间复杂度为O(1)。

对数型O(log n)

  • 常用代码执行次数为x,n为目标数字。符合2^x=n,推导出x=log2(n)(log n)的情况
  • 算法消耗随n的增长而增长,性能较好
let n = 100;
let i = 1;
while(i<n){
    i = i * 2
}

代码分析:
i为128。
n为100,时间复杂度为O(log2(100))。
因为Math.log2(100)≈6.64,所以最终的时间复杂度为O(6.65)。

线性型O(n)

  • 常见于一次for循环,while循环
  • 算法消耗随n的增长而增长,性能一般
  • 无论n值有多大,即使是Inifinity,时间复杂度都为O(n)
let n = 100;
let j = 0;
for(let i = 0;i<n;i++){
    j = i;
}

代码分析:
i为100,j为99。
n为100,时间复杂度为O(100)。

线性对数型O(n log n)

  • 常用于一个对时间复杂度为O(log2(n))的代码执行一个n次循环
  • 算法消耗随n的增长而增长,性能较差
let n = 100;
for(let m = 0; m<n; m++){
    let i = 1;
    while(i<n){
        i = i * 2
    }
}

代码分析:
i为128。
m为100,n为100,时间复杂度为O(m log2(n))。
因为100* Math.log2(100)≈664.39,所以最终的时间复杂度为O(664.39)。

平方型O(n^2)、立方型O(n^3)、K次方型O(n^k)

  • 最常见的算法时间复杂度,可用于快速开发业务逻辑
  • 常见于2次for循环,或者3次for循环,以及k次for循环
  • 算法消耗随n的增长而增长,性能糟糕
  • 实际开发过程中,不建议使用K值过大的循环,否则代码将非常难以维护
let n = 100
let v = 0;
for(let i =0;i<n;i++){
    for(let j = 0; j<n; j++){
        v = v+j+i;
    }
}

代码分析:
v为990000,i为100,j为100.
n为100,时间复杂度为O(100^2)。
也就是O(10000)。

立方型O(n^3)、K次方型O(n^k)和平方型O(n^2)类似,无非是多了几次循环。

// 立方型O(n^3)
for(let i =0;i<n;i++){
    for(let j = 0; j<n; j++){
        for(let m = 0; m<n; m++){

        }
    }
}
// K次方型O(n^k)
for(let i =0;i<n;i++){
    for(let j = 0; j<n; j++){
        for(let m = 0; m<n; m++){
            for(let p = 0; p<n; p++){
                ... // for循环继续嵌套下去,k值不断增大
            }
        }
    }
}

平方底指数型O(2^n)、立方底指数型O(3^n)、K次底指数型O(k^n)

  • 常见于2次递归的情况,3次递归以及k次递归的情况
  • 算法消耗随n的增长而增长,性能糟糕
  • 实际开发过程中,k为1时,一次递归的时间复杂度为O(1)。因为O(1^n)无论n为多少都为O(1)。

斐波那契数列(兔子数列、黄金分割数列):1、1、2、3、5、8、13、21、34···
题目:leetcode 509 斐波那契数
题解:[509.斐波那契数列 (Fibonacci Number)]https://github.com/FrankKai/l...

/**
 * @param {number} N
 * @return {number}
 */
var fib = function (N) {
  /**
   * 解法1: 递归
   * 性能:  88ms 34.2MB
   * 时间复杂度:O(2^N)
   */
  if (N <= 1) return N;
  return fib(N - 1) + fib(N - 2);
};

假设N等于100。
代码分析:
结果为 xxx。
因为浏览器直接卡死。nodejs中也运行不出来
具体原因则是2的100次方真的太大了。算不来。
N为100,时间复杂度为O(2^100)。
因为Math.pow(2, 100)= 1.2676506002282294e+30,所以最终的时间复杂度为O(1.2676506002282294e+30)。大到爆表。

立方底指数型O(3^n)、K次底指数型O(k^n)与平方底指数型O(2^n)类似,只不过基数变为了3和k。

O(Math.pow(3, n))
O(Math.pow(k, n))

假设n为100,假设k为5。
Math.pow(3, n)为5.153775207320113e+47。
Math.pow(5, n)为7.888609052210118e+69。
时间复杂度也是巨高,真的是指数爆炸?。

更多的斐波那契数列时间复杂度O(2^N)的分析可以查看下文中的:如何理解斐波那契数列的时间复杂度O(2^N)?

阶乘型O(n!)

  • 极其不常见
  • 算法消耗随n的增长而增长,性能糟糕
function nFacRuntimeFunc(n) {
  for(let i=0; i<n; i++) {
      nFacRuntimeFunc(n-1);
  }
}

阶乘型O(n!)的时间复杂度按照(n!+(n-1)!+(n-2)!+ ··· + 1) +((n-1)!+(n-2)!+ ··· + 1)+ ··· 的方式去计算。
注意哦,这里是多个阶乘的和。不仅仅是n * (n-1) * (n-2) * (n-3)···1
假设n从0到10,它的算法复杂度O(n!)依次为1,4,15,64,325,1956,13699,109600,986409,9864100···
为了和上文中的其它算法复杂度做比较,n为100时是多少呢?
**O(2^n)为10才是1024,n为100时O(2^n)直接浏览器卡死了。
O(n!)才为10就接近1000万了,真要是n设置成100,计算到机器烧了也计算不出吧。**
所以n为100时的O(n!)就不要想了,庞大到恐怖的一个数字。

更多的阶乘型时间复杂度O(n!)的分析可以查看下文中的:如何理解阶乘型算法复杂度O(n!)?

如何理解斐波那契数列的时间复杂度O(2^N)?

O(2^N)
  • Math.pow(base, ex),2个递归所以base是2。
  • N的话是因为N->∞,但其实真正是O(2^(N-1))。
/**
 * @param {number} N
 * @return {number}
 */
var fib = function (N) {
    /**
     * 解法1: 递归
     * 性能:  88ms 34.2MB
     */
    console.log('foo');
    if (N <= 1) return N;
    return fib(N - 1) + fib(N - 2)
};
N打印foo数O(2^N)
11O(2^0)
22^1 + 1O(2^1)
32^2 + 1O(2^2 )
42^3 + 1O(2^3 )
52^4 + 1O(2^4 )

通过上表我们分析得到:
如果包含1的话,严格来讲时间复杂度是O(2^(N-1))。
如果从N>1开始计算,时间复杂度确实是O(2^N)。
斐波那契数列非常长,N->∞,因此可以将斐波那契数列的时间复杂度直接看做是O(2^N)。

如何理解阶乘型时间复杂度O(n!)?

O(N!)

我们把上面的代码改造一下,增加一个count用来统计O(n!)。

let count = 0;
function nFacRuntimeFunc(n) {
  for(let i=0; i<n; i++) {
      count++;
      nFacRuntimeFunc(n-1);
  }
}

阶乘型O(n!)的时间复杂度按照(n!+(n-1)!+(n-2)!+ ··· + 1) +((n-1)!+(n-2)!+ ··· + 1) 的方式去计算。
注意哦,这里是多个阶乘的和。不仅仅是n * (n-1) * (n-2) * (n-3)···1
上述示例中的count即为复杂度的值。

n多次n! + (n-1)! + ··· + 1!countO(n!)
111O(1)
2(2!+1!) +(1!)4O(4)
3(3!+(2!+1!)+1!)+((2!+1!)+1!)+(1!)15O(15)
4...64O(64)
5...325O(325)
6...1956O(1956)
7...13699O(13699)
8...109600O(109600)
9...986409O(986409)
10...9864100O(9864100)

快看看这个表格吧,n为10的时候O(n!)达到了O(9864100),接近了O(一千万)。这种算法的性能真的是糟糕到极致了。

参考资料

https://juejin.im/post/5e7c09...
https://zhuanlan.zhihu.com/p/...
https://www.bigocheatsheet.com/
https://stackoverflow.com/que...

期待和大家交流,共同进步,欢迎大家加入我创建的与前端开发密切相关的技术讨论小组:

努力成为优秀前端工程师!
查看原文

认证与成就

  • 获得 305 次点赞
  • 获得 20 枚徽章 获得 3 枚金徽章, 获得 7 枚银徽章, 获得 10 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-02-19
个人主页被 2.8k 人浏览