Customizer: Fix scalability performance problem for previewing multidimensional settings.

As the number of multidimensional settings (serialized options and theme mods) increase for a given ID base (e.g. a widget of a certain type), the number of calls to the `multidimensional` methods on `WP_Customize_Setting` increase exponentially, and the time for the preview to refresh grows in time exponentially as well.

To improve performance, this change reduces the number of filters needed to preview the settings off of a multidimensional root from N to 1. This improves performance from `O(n^2)` to `O(n)`, but the linear increase is so low that the performance is essentially `O(1)` in comparison. This is achieved by introducing the concept of an "aggregated multidimensional" setting, where the root value of the multidimensional serialized setting value gets cached in a static array variable shared across all settings.

Also improves performance by only adding preview filters if there is actually a need to do so: there is no need to add a filter if there is an initial value and if there is no posted value for a given setting (if it is not dirty).

Fixes #32103.

Built from https://develop.svn.wordpress.org/trunk@35007


git-svn-id: http://core.svn.wordpress.org/trunk@34972 1a063a9b-81f0-0310-95a4-ce76da25c4cd
This commit is contained in:
Weston Ruter 2015-10-10 09:06:25 +00:00
parent ac9a85a45e
commit 1fe64b1c65
2 changed files with 331 additions and 115 deletions

View File

@ -81,6 +81,25 @@ class WP_Customize_Setting {
*/
protected $id_data = array();
/**
* Cache of multidimensional values to improve performance.
*
* @since 4.4.0
* @access protected
* @var array
* @static
*/
protected static $aggregated_multidimensionals = array();
/**
* Whether the multidimensional setting is aggregated.
*
* @since 4.4.0
* @access protected
* @var bool
*/
protected $is_multidimensional_aggregated = false;
/**
* Constructor.
*
@ -96,27 +115,80 @@ class WP_Customize_Setting {
public function __construct( $manager, $id, $args = array() ) {
$keys = array_keys( get_object_vars( $this ) );
foreach ( $keys as $key ) {
if ( isset( $args[ $key ] ) )
if ( isset( $args[ $key ] ) ) {
$this->$key = $args[ $key ];
}
}
$this->manager = $manager;
$this->id = $id;
// Parse the ID for array keys.
$this->id_data[ 'keys' ] = preg_split( '/\[/', str_replace( ']', '', $this->id ) );
$this->id_data[ 'base' ] = array_shift( $this->id_data[ 'keys' ] );
$this->id_data['keys'] = preg_split( '/\[/', str_replace( ']', '', $this->id ) );
$this->id_data['base'] = array_shift( $this->id_data['keys'] );
// Rebuild the ID.
$this->id = $this->id_data[ 'base' ];
if ( ! empty( $this->id_data[ 'keys' ] ) )
$this->id .= '[' . implode( '][', $this->id_data[ 'keys' ] ) . ']';
if ( ! empty( $this->id_data[ 'keys' ] ) ) {
$this->id .= '[' . implode( '][', $this->id_data['keys'] ) . ']';
}
if ( $this->sanitize_callback )
if ( $this->sanitize_callback ) {
add_filter( "customize_sanitize_{$this->id}", $this->sanitize_callback, 10, 2 );
if ( $this->sanitize_js_callback )
}
if ( $this->sanitize_js_callback ) {
add_filter( "customize_sanitize_js_{$this->id}", $this->sanitize_js_callback, 10, 2 );
}
if ( 'option' === $this->type || 'theme_mod' === $this->type ) {
// Other setting types can opt-in to aggregate multidimensional explicitly.
$this->aggregate_multidimensional();
}
}
/**
* Get parsed ID data for multidimensional setting.
*
* @since 4.4.0
* @access public
*
* @return array {
* ID data for multidimensional setting.
*
* @type string $base ID base
* @type array $keys Keys for multidimensional array.
* }
*/
final public function id_data() {
return $this->id_data;
}
/**
* Set up the setting for aggregated multidimensional values.
*
* When a multidimensional setting gets aggregated, all of its preview and update
* calls get combined into one call, greatly improving performance.
*
* @since 4.4.0
* @access protected
*/
protected function aggregate_multidimensional() {
if ( empty( $this->id_data['keys'] ) ) {
return;
}
$id_base = $this->id_data['base'];
if ( ! isset( self::$aggregated_multidimensionals[ $this->type ] ) ) {
self::$aggregated_multidimensionals[ $this->type ] = array();
}
if ( ! isset( self::$aggregated_multidimensionals[ $this->type ][ $id_base ] ) ) {
self::$aggregated_multidimensionals[ $this->type ][ $id_base ] = array(
'previewed_instances' => array(), // Calling preview() will add the $setting to the array.
'preview_applied_instances' => array(), // Flags for which settings have had their values applied.
'root_value' => $this->get_root_value( array() ), // Root value for initial state, manipulated by preview and update calls.
);
}
$this->is_multidimensional_aggregated = true;
}
/**
@ -153,29 +225,76 @@ class WP_Customize_Setting {
protected $_original_value;
/**
* Set up filters for the setting so that the preview request
* will render the drafted changes.
* Add filters to supply the setting's value when accessed.
*
* If the setting already has a pre-existing value and there is no incoming
* post value for the setting, then this method will short-circuit since
* there is no change to preview.
*
* @since 3.4.0
* @since 4.4.0 Added boolean return value.
* @access public
*
* @return bool False when preview short-circuits due no change needing to be previewed.
*/
public function preview() {
if ( ! isset( $this->_original_value ) ) {
$this->_original_value = $this->value();
}
if ( ! isset( $this->_previewed_blog_id ) ) {
$this->_previewed_blog_id = get_current_blog_id();
}
$id_base = $this->id_data['base'];
$is_multidimensional = ! empty( $this->id_data['keys'] );
$multidimensional_filter = array( $this, '_multidimensional_preview_filter' );
switch( $this->type ) {
/*
* Check if the setting has a pre-existing value (an isset check),
* and if doesn't have any incoming post value. If both checks are true,
* then the preview short-circuits because there is nothing that needs
* to be previewed.
*/
$undefined = new stdClass();
$needs_preview = ( $undefined !== $this->post_value( $undefined ) );
$value = null;
// Since no post value was defined, check if we have an initial value set.
if ( ! $needs_preview ) {
if ( $this->is_multidimensional_aggregated ) {
$root = self::$aggregated_multidimensionals[ $this->type ][ $id_base ]['root_value'];
$value = $this->multidimensional_get( $root, $this->id_data['keys'], $undefined );
} else {
$default = $this->default;
$this->default = $undefined; // Temporarily set default to undefined so we can detect if existing value is set.
$value = $this->value();
$this->default = $default;
}
$needs_preview = ( $undefined === $value ); // Because the default needs to be supplied.
}
if ( ! $needs_preview ) {
return false;
}
switch ( $this->type ) {
case 'theme_mod' :
add_filter( 'theme_mod_' . $this->id_data[ 'base' ], array( $this, '_preview_filter' ) );
if ( ! $is_multidimensional ) {
add_filter( "theme_mod_{$id_base}", array( $this, '_preview_filter' ) );
} else {
if ( empty( self::$aggregated_multidimensionals[ $this->type ][ $id_base ]['previewed_instances'] ) ) {
// Only add this filter once for this ID base.
add_filter( "theme_mod_{$id_base}", $multidimensional_filter );
}
self::$aggregated_multidimensionals[ $this->type ][ $id_base ]['previewed_instances'][ $this->id ] = $this;
}
break;
case 'option' :
if ( empty( $this->id_data[ 'keys' ] ) )
add_filter( 'pre_option_' . $this->id_data[ 'base' ], array( $this, '_preview_filter' ) );
else {
add_filter( 'option_' . $this->id_data[ 'base' ], array( $this, '_preview_filter' ) );
add_filter( 'default_option_' . $this->id_data[ 'base' ], array( $this, '_preview_filter' ) );
if ( ! $is_multidimensional ) {
add_filter( "pre_option_{$id_base}", array( $this, '_preview_filter' ) );
} else {
if ( empty( self::$aggregated_multidimensionals[ $this->type ][ $id_base ]['previewed_instances'] ) ) {
// Only add these filters once for this ID base.
add_filter( "option_{$id_base}", $multidimensional_filter );
add_filter( "default_option_{$id_base}", $multidimensional_filter );
}
self::$aggregated_multidimensionals[ $this->type ][ $id_base ]['previewed_instances'][ $this->id ] = $this;
}
break;
default :
@ -204,17 +323,17 @@ class WP_Customize_Setting {
*/
do_action( "customize_preview_{$this->type}", $this );
}
return true;
}
/**
* Callback function to filter the theme mods and options.
* Callback function to filter non-multidimensional theme mods and options.
*
* If switch_to_blog() was called after the preview() method, and the current
* blog is now not the same blog, then this method does a no-op and returns
* the original value.
*
* @since 3.4.0
* @uses WP_Customize_Setting::multidimensional_replace()
*
* @param mixed $original Old value.
* @return mixed New or old value.
@ -224,15 +343,63 @@ class WP_Customize_Setting {
return $original;
}
$undefined = new stdClass(); // symbol hack
$undefined = new stdClass(); // Symbol hack.
$post_value = $this->post_value( $undefined );
if ( $undefined === $post_value ) {
$value = $this->_original_value;
} else {
if ( $undefined !== $post_value ) {
$value = $post_value;
} else {
/*
* Note that we don't use $original here because preview() will
* not add the filter in the first place if it has an initial value
* and there is no post value.
*/
$value = $this->default;
}
return $value;
}
/**
* Callback function to filter multidimensional theme mods and options.
*
* For all multidimensional settings of a given type, the preview filter for
* the first setting previewed will be used to apply the values for the others.
*
* @since 4.4.0
* @access public
*
* @see WP_Customize_Setting::$aggregated_multidimensionals
* @param mixed $original Original root value.
* @return mixed New or old value.
*/
public function _multidimensional_preview_filter( $original ) {
if ( ! $this->is_current_blog_previewed() ) {
return $original;
}
return $this->multidimensional_replace( $original, $this->id_data['keys'], $value );
$id_base = $this->id_data['base'];
// If no settings have been previewed yet (which should not be the case, since $this is), just pass through the original value.
if ( empty( self::$aggregated_multidimensionals[ $this->type ][ $id_base ]['previewed_instances'] ) ) {
return $original;
}
foreach ( self::$aggregated_multidimensionals[ $this->type ][ $id_base ]['previewed_instances'] as $previewed_setting ) {
// Skip applying previewed value for any settings that have already been applied.
if ( ! empty( self::$aggregated_multidimensionals[ $this->type ][ $id_base ]['preview_applied_instances'][ $previewed_setting->id ] ) ) {
continue;
}
// Do the replacements of the posted/default sub value into the root value.
$value = $previewed_setting->post_value( $previewed_setting->default );
$root = self::$aggregated_multidimensionals[ $previewed_setting->type ][ $id_base ]['root_value'];
$root = $previewed_setting->multidimensional_replace( $root, $previewed_setting->id_data['keys'], $value );
self::$aggregated_multidimensionals[ $previewed_setting->type ][ $id_base ]['root_value'] = $root;
// Mark this setting having been applied so that it will be skipped when the filter is called again.
self::$aggregated_multidimensionals[ $previewed_setting->type ][ $id_base ]['preview_applied_instances'][ $previewed_setting->id ] = true;
}
return self::$aggregated_multidimensionals[ $this->type ][ $id_base ]['root_value'];
}
/**
@ -298,6 +465,57 @@ class WP_Customize_Setting {
return apply_filters( "customize_sanitize_{$this->id}", $value, $this );
}
/**
* Get the root value for a setting, especially for multidimensional ones.
*
* @since 4.4.0
* @access protected
*
* @param mixed $default Value to return if root does not exist.
* @return mixed
*/
protected function get_root_value( $default = null ) {
$id_base = $this->id_data['base'];
if ( 'option' === $this->type ) {
return get_option( $id_base, $default );
} else if ( 'theme_mod' ) {
return get_theme_mod( $id_base, $default );
} else {
/*
* Any WP_Customize_Setting subclass implementing aggregate multidimensional
* will need to override this method to obtain the data from the appropriate
* location.
*/
return $default;
}
}
/**
* Set the root value for a setting, especially for multidimensional ones.
*
* @since 4.4.0
* @access protected
*
* @param mixed $value Value to set as root of multidimensional setting.
* @return bool Whether the multidimensional root was updated successfully.
*/
protected function set_root_value( $value ) {
$id_base = $this->id_data['base'];
if ( 'option' === $this->type ) {
return update_option( $id_base, $value );
} else if ( 'theme_mod' ) {
set_theme_mod( $id_base, $value );
return true;
} else {
/*
* Any WP_Customize_Setting subclass implementing aggregate multidimensional
* will need to override this method to obtain the data from the appropriate
* location.
*/
return false;
}
}
/**
* Save the value of the setting, using the related API.
*
@ -307,72 +525,52 @@ class WP_Customize_Setting {
* @return bool The result of saving the value.
*/
protected function update( $value ) {
switch ( $this->type ) {
case 'theme_mod' :
$this->_update_theme_mod( $value );
return true;
$id_base = $this->id_data['base'];
if ( 'option' === $this->type || 'theme_mod' === $this->type ) {
if ( ! $this->is_multidimensional_aggregated ) {
return $this->set_root_value( $value );
} else {
$root = self::$aggregated_multidimensionals[ $this->type ][ $id_base ]['root_value'];
$root = $this->multidimensional_replace( $root, $this->id_data['keys'], $value );
self::$aggregated_multidimensionals[ $this->type ][ $id_base ]['root_value'] = $root;
return $this->set_root_value( $root );
}
} else {
/**
* Fires when the {@see WP_Customize_Setting::update()} method is called for settings
* not handled as theme_mods or options.
*
* The dynamic portion of the hook name, `$this->type`, refers to the type of setting.
*
* @since 3.4.0
*
* @param mixed $value Value of the setting.
* @param WP_Customize_Setting $this WP_Customize_Setting instance.
*/
do_action( "customize_update_{$this->type}", $value, $this );
case 'option' :
return $this->_update_option( $value );
default :
/**
* Fires when the {@see WP_Customize_Setting::update()} method is called for settings
* not handled as theme_mods or options.
*
* The dynamic portion of the hook name, `$this->type`, refers to the type of setting.
*
* @since 3.4.0
*
* @param mixed $value Value of the setting.
* @param WP_Customize_Setting $this WP_Customize_Setting instance.
*/
do_action( "customize_update_{$this->type}", $value, $this );
return has_action( "customize_update_{$this->type}" );
return has_action( "customize_update_{$this->type}" );
}
}
/**
* Update the theme mod from the value of the parameter.
* Deprecated method.
*
* @since 3.4.0
*
* @param mixed $value The value to update.
* @deprecated 4.4.0 Deprecated in favor of update() method.
*/
protected function _update_theme_mod( $value ) {
// Handle non-array theme mod.
if ( empty( $this->id_data[ 'keys' ] ) ) {
set_theme_mod( $this->id_data[ 'base' ], $value );
return;
}
// Handle array-based theme mod.
$mods = get_theme_mod( $this->id_data[ 'base' ] );
$mods = $this->multidimensional_replace( $mods, $this->id_data[ 'keys' ], $value );
if ( isset( $mods ) ) {
set_theme_mod( $this->id_data[ 'base' ], $mods );
}
protected function _update_theme_mod() {
_deprecated_function( __METHOD__, '4.4.0', __CLASS__ . '::update()' );
}
/**
* Update the option from the value of the setting.
* Deprecated method.
*
* @since 3.4.0
*
* @param mixed $value The value to update.
* @return bool The result of saving the value.
* @deprecated 4.4.0 Deprecated in favor of update() method.
*/
protected function _update_option( $value ) {
// Handle non-array option.
if ( empty( $this->id_data[ 'keys' ] ) )
return update_option( $this->id_data[ 'base' ], $value );
// Handle array-based options.
$options = get_option( $this->id_data[ 'base' ] );
$options = $this->multidimensional_replace( $options, $this->id_data[ 'keys' ], $value );
if ( isset( $options ) )
return update_option( $this->id_data[ 'base' ], $options );
protected function _update_option() {
_deprecated_function( __METHOD__, '4.4.0', __CLASS__ . '::update()' );
}
/**
@ -383,39 +581,33 @@ class WP_Customize_Setting {
* @return mixed The value.
*/
public function value() {
// Get the callback that corresponds to the setting type.
switch( $this->type ) {
case 'theme_mod' :
$function = 'get_theme_mod';
break;
case 'option' :
$function = 'get_option';
break;
default :
$id_base = $this->id_data['base'];
$is_core_type = ( 'option' === $this->type || 'theme_mod' === $this->type );
/**
* Filter a Customize setting value not handled as a theme_mod or option.
*
* The dynamic portion of the hook name, `$this->id_date['base']`, refers to
* the base slug of the setting name.
*
* For settings handled as theme_mods or options, see those corresponding
* functions for available hooks.
*
* @since 3.4.0
*
* @param mixed $default The setting default value. Default empty.
*/
return apply_filters( 'customize_value_' . $this->id_data[ 'base' ], $this->default );
if ( ! $is_core_type && ! $this->is_multidimensional_aggregated ) {
$value = $this->get_root_value( $this->default );
/**
* Filter a Customize setting value not handled as a theme_mod or option.
*
* The dynamic portion of the hook name, `$this->id_date['base']`, refers to
* the base slug of the setting name.
*
* For settings handled as theme_mods or options, see those corresponding
* functions for available hooks.
*
* @since 3.4.0
*
* @param mixed $default The setting default value. Default empty.
*/
$value = apply_filters( "customize_value_{$id_base}", $value );
} else if ( $this->is_multidimensional_aggregated ) {
$root_value = self::$aggregated_multidimensionals[ $this->type ][ $id_base ]['root_value'];
$value = $this->multidimensional_get( $root_value, $this->id_data['keys'], $this->default );
} else {
$value = $this->get_root_value( $this->default );
}
// Handle non-array value
if ( empty( $this->id_data[ 'keys' ] ) )
return $function( $this->id_data[ 'base' ], $this->default );
// Handle array-based value
$values = $function( $this->id_data[ 'base' ] );
return $this->multidimensional_get( $values, $this->id_data[ 'keys' ], $this->default );
return $value;
}
/**
@ -520,7 +712,7 @@ class WP_Customize_Setting {
* @param $root
* @param $keys
* @param mixed $value The value to update.
* @return
* @return mixed
*/
final protected function multidimensional_replace( $root, $keys, $value ) {
if ( ! isset( $value ) )
@ -853,7 +1045,7 @@ class WP_Customize_Nav_Menu_Item_Setting extends WP_Customize_Setting {
}
/**
* Get the instance data for a given widget setting.
* Get the instance data for a given nav_menu_item setting.
*
* @since 4.3.0
* @access public
@ -987,13 +1179,23 @@ class WP_Customize_Nav_Menu_Item_Setting extends WP_Customize_Setting {
* Handle previewing the setting.
*
* @since 4.3.0
* @since 4.4.0 Added boolean return value.
* @access public
*
* @see WP_Customize_Manager::post_value()
*
* @return bool False if method short-circuited due to no-op.
*/
public function preview() {
if ( $this->is_previewed ) {
return;
return false;
}
$undefined = new stdClass();
$is_placeholder = ( $this->post_id < 0 );
$is_dirty = ( $undefined !== $this->post_value( $undefined ) );
if ( ! $is_placeholder && ! $is_dirty ) {
return false;
}
$this->is_previewed = true;
@ -1009,6 +1211,8 @@ class WP_Customize_Nav_Menu_Item_Setting extends WP_Customize_Setting {
}
// @todo Add get_post_metadata filters for plugins to add their data.
return true;
}
/**
@ -1621,13 +1825,23 @@ class WP_Customize_Nav_Menu_Setting extends WP_Customize_Setting {
* Handle previewing the setting.
*
* @since 4.3.0
* @since 4.4.0 Added boolean return value
* @access public
*
* @see WP_Customize_Manager::post_value()
*
* @return bool False if method short-circuited due to no-op.
*/
public function preview() {
if ( $this->is_previewed ) {
return;
return false;
}
$undefined = new stdClass();
$is_placeholder = ( $this->term_id < 0 );
$is_dirty = ( $undefined !== $this->post_value( $undefined ) );
if ( ! $is_placeholder && ! $is_dirty ) {
return false;
}
$this->is_previewed = true;
@ -1638,6 +1852,8 @@ class WP_Customize_Nav_Menu_Setting extends WP_Customize_Setting {
add_filter( 'wp_get_nav_menu_object', array( $this, 'filter_wp_get_nav_menu_object' ), 10, 2 );
add_filter( 'default_option_nav_menu_options', array( $this, 'filter_nav_menu_options' ) );
add_filter( 'option_nav_menu_options', array( $this, 'filter_nav_menu_options' ) );
return true;
}
/**

View File

@ -4,7 +4,7 @@
*
* @global string $wp_version
*/
$wp_version = '4.4-alpha-35006';
$wp_version = '4.4-alpha-35007';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.